|
| 1 | +#!/usr/bin/env python |
| 2 | + |
| 3 | +""" |
| 4 | +Copyright (c) 2006-2025 sqlmap developers (https://sqlmap.org) |
| 5 | +See the file 'LICENSE' for copying permission |
| 6 | +""" |
| 7 | + |
| 8 | +import os |
| 9 | +import re |
| 10 | + |
| 11 | +try: |
| 12 | + from lib.core.data import kb |
| 13 | + from lib.core.data import paths |
| 14 | + from lib.core.common import getFileItems |
| 15 | +except ImportError: |
| 16 | + kb = paths = None |
| 17 | + getFileItems = None |
| 18 | + |
| 19 | +# Token type constants (kept short/local; this is a self-contained lexer) |
| 20 | +T_WS = "ws" |
| 21 | +T_LCOMMENT = "lcomment" |
| 22 | +T_BCOMMENT = "bcomment" |
| 23 | +T_STR = "str" # closed string literal ('...' or "...") |
| 24 | +T_UNTERM = "unterm" # unterminated string literal (open quote to end) |
| 25 | +T_QID = "qid" # quoted identifier (`...` or [...]) |
| 26 | +T_NUM = "num" |
| 27 | +T_IDENT = "ident" # bare identifier (not a keyword) |
| 28 | +T_KEYWORD = "keyword" # identifier whose upper() is a known SQL keyword |
| 29 | +T_OP = "op" |
| 30 | +T_COMMA = "comma" |
| 31 | +T_DOT = "dot" |
| 32 | +T_SEMI = "semi" |
| 33 | +T_LPAREN = "lparen" |
| 34 | +T_RPAREN = "rparen" |
| 35 | +T_OTHER = "other" # anything the lexer could not classify |
| 36 | + |
| 37 | +# Master lexer: ORDER MATTERS (longer / more specific patterns first) |
| 38 | +_LEXER = re.compile(r""" |
| 39 | + (?P<%s>\s+) |
| 40 | + | (?P<%s>(?:--|\#)[^\n]*) |
| 41 | + | (?P<%s>/\*.*?\*/) |
| 42 | + | (?P<%s>'(?:''|[^'])*'|"(?:""|[^"])*") |
| 43 | + | (?P<%s>`[^`]*`|\[[^\]]*\]) |
| 44 | + | (?P<%s>0[xX][0-9A-Fa-f]+|(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?) |
| 45 | + | (?P<%s>[A-Za-z_@$][A-Za-z0-9_@$]*) |
| 46 | + | (?P<%s><=|>=|<>|!=|==|<<|>>|\|\||&&|::|:=|[-+*/%%=<>!~&|^:]) |
| 47 | + | (?P<%s>,) |
| 48 | + | (?P<%s>\.) |
| 49 | + | (?P<%s>;) |
| 50 | + | (?P<%s>\() |
| 51 | + | (?P<%s>\)) |
| 52 | +""" % (T_WS, T_LCOMMENT, T_BCOMMENT, T_STR, T_QID, T_NUM, T_IDENT, T_OP, |
| 53 | + T_COMMA, T_DOT, T_SEMI, T_LPAREN, T_RPAREN), re.VERBOSE | re.DOTALL) |
| 54 | + |
| 55 | +# operand-producing token types (something that evaluates to a value) |
| 56 | +_OPERANDS = frozenset((T_NUM, T_STR, T_IDENT, T_QID, T_RPAREN)) |
| 57 | + |
| 58 | +# operands trustworthy as the left side of a "missing separator" check. |
| 59 | +# a string is excluded because break-out payloads routinely produce a fake |
| 60 | +# merged string (e.g. "1' AND '1"->"' AND '") followed by a bare number; a |
| 61 | +# number is excluded because some dialects legitimately space-separate two |
| 62 | +# numbers (e.g. HSQLDB "LIMIT <offset> <limit>") |
| 63 | +_HARD_OPERANDS = frozenset((T_IDENT, T_RPAREN)) |
| 64 | + |
| 65 | +# binary keyword operators (need an operand on both sides) |
| 66 | +_BINARY_KEYWORDS = frozenset(("AND", "OR", "XOR", "LIKE", "RLIKE", "REGEXP", "DIV", "MOD")) |
| 67 | + |
| 68 | +# binary symbolic operators (unary +/-/~ excluded; '*' excluded as it doubles |
| 69 | +# as the SELECT/COUNT wildcard) |
| 70 | +_BINARY_SYMBOLS = frozenset(("=", "<>", "!=", "<", ">", "<=", ">=", "/", "%", "||", "&&", "|", "&", "^")) |
| 71 | + |
| 72 | +_KEYWORDS_CACHE = None |
| 73 | + |
| 74 | + |
| 75 | +class Token(object): |
| 76 | + __slots__ = ("type", "value", "start", "end") |
| 77 | + |
| 78 | + def __init__(self, type_, value, start, end): |
| 79 | + self.type = type_ |
| 80 | + self.value = value |
| 81 | + self.start = start |
| 82 | + self.end = end |
| 83 | + |
| 84 | + |
| 85 | +def _keywords(): |
| 86 | + global _KEYWORDS_CACHE |
| 87 | + |
| 88 | + if kb is not None and getattr(kb, "keywords", None): |
| 89 | + return kb.keywords |
| 90 | + |
| 91 | + if _KEYWORDS_CACHE is not None: |
| 92 | + return _KEYWORDS_CACHE |
| 93 | + |
| 94 | + retVal = set() |
| 95 | + |
| 96 | + candidate = None |
| 97 | + if paths is not None and getattr(paths, "SQL_KEYWORDS", None): |
| 98 | + candidate = paths.SQL_KEYWORDS |
| 99 | + else: |
| 100 | + # self-sufficient fallback (e.g. bare doctest run before boot) |
| 101 | + candidate = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), "data", "txt", "keywords.txt") |
| 102 | + |
| 103 | + try: |
| 104 | + if getFileItems is not None: |
| 105 | + retVal = set(getFileItems(candidate)) |
| 106 | + else: |
| 107 | + with open(candidate) as f: |
| 108 | + retVal = set(_.strip().upper() for _ in f if _.strip() and not _.startswith('#')) |
| 109 | + except Exception: |
| 110 | + pass |
| 111 | + |
| 112 | + _KEYWORDS_CACHE = retVal |
| 113 | + return retVal |
| 114 | + |
| 115 | + |
| 116 | +def tokenize(sql, keywords=None): |
| 117 | + """ |
| 118 | + Fragment-tolerant lexer. Returns a list of Token objects (whitespace kept |
| 119 | + so callers can reason about token gluing, e.g. '1UNION'). |
| 120 | +
|
| 121 | + >>> [t.type for t in tokenize("id 1") if t.type != 'ws'] |
| 122 | + ['ident', 'num'] |
| 123 | + >>> [t.type for t in tokenize("1foo") if t.type != 'ws'] |
| 124 | + ['num', 'ident'] |
| 125 | + """ |
| 126 | + if keywords is None: |
| 127 | + keywords = _keywords() |
| 128 | + |
| 129 | + retVal = [] |
| 130 | + pos = 0 |
| 131 | + length = len(sql) |
| 132 | + |
| 133 | + while pos < length: |
| 134 | + match = _LEXER.match(sql, pos) |
| 135 | + if match: |
| 136 | + type_ = match.lastgroup |
| 137 | + value = match.group() |
| 138 | + if type_ == T_IDENT and value.upper() in keywords: |
| 139 | + type_ = T_KEYWORD |
| 140 | + retVal.append(Token(type_, value, pos, match.end())) |
| 141 | + pos = match.end() |
| 142 | + else: |
| 143 | + char = sql[pos] |
| 144 | + if char in "'\"`[": |
| 145 | + # an opening quote/bracket that never closes -> unterminated to end |
| 146 | + retVal.append(Token(T_UNTERM, sql[pos:], pos, length)) |
| 147 | + pos = length |
| 148 | + else: |
| 149 | + retVal.append(Token(T_OTHER, char, pos, pos + 1)) |
| 150 | + pos += 1 |
| 151 | + |
| 152 | + return retVal |
| 153 | + |
| 154 | + |
| 155 | +def _significant(tokens): |
| 156 | + """Tokens that carry structure (drop whitespace and comments).""" |
| 157 | + return [_ for _ in tokens if _.type not in (T_WS, T_LCOMMENT, T_BCOMMENT)] |
| 158 | + |
| 159 | + |
| 160 | +def _isBinary(token): |
| 161 | + if token.type == T_KEYWORD: |
| 162 | + return token.value.upper() in _BINARY_KEYWORDS |
| 163 | + if token.type == T_OP: |
| 164 | + return token.value in _BINARY_SYMBOLS |
| 165 | + return False |
| 166 | + |
| 167 | + |
| 168 | +def checkSanity(sql, keywords=None): |
| 169 | + """ |
| 170 | + Fragment-tolerant SQL sanity check. Models locally-valid SQL and reports |
| 171 | + only *interior* impossibilities - constructs that no server-side prefix or |
| 172 | + suffix could ever make legal. Dangling quotes/parens at the edges are |
| 173 | + tolerated (the surrounding query supplies the other half). |
| 174 | +
|
| 175 | + Returns a list of human-readable issue strings (empty == looks sane). |
| 176 | +
|
| 177 | + Assumes SQL keyword operators (AND/OR/LIKE/...) are used as operators, not |
| 178 | + as user identifiers named after a keyword (some engines, e.g. SQLite, allow |
| 179 | + a column literally named "LIKE") - injection payloads never do the latter. |
| 180 | +
|
| 181 | + >>> checkSanity("1 AND 1=1") |
| 182 | + [] |
| 183 | + >>> checkSanity("1') UNION SELECT NULL-- -") |
| 184 | + [] |
| 185 | + >>> bool(checkSanity("(SELECT id 1 FROM users)")) |
| 186 | + True |
| 187 | + >>> bool(checkSanity("1UNION SELECT NULL")) |
| 188 | + True |
| 189 | + """ |
| 190 | + if not sql: |
| 191 | + return [] |
| 192 | + |
| 193 | + if keywords is None: |
| 194 | + keywords = _keywords() |
| 195 | + |
| 196 | + issues = [] |
| 197 | + tokens = tokenize(sql, keywords) |
| 198 | + |
| 199 | + # -- edge tolerance for unterminated strings --------------------------- |
| 200 | + # A trailing open quote at paren-depth 0 is a legitimate break-out. One |
| 201 | + # that opens *inside* a group (depth > 0) has swallowed a needed ')', i.e. |
| 202 | + # an odd quote count within an owned scope (the classic "users'" abomination). |
| 203 | + depth = 0 |
| 204 | + for token in tokens: |
| 205 | + if token.type == T_LPAREN: |
| 206 | + depth += 1 |
| 207 | + elif token.type == T_RPAREN: |
| 208 | + depth -= 1 |
| 209 | + elif token.type == T_UNTERM: |
| 210 | + if depth > 0: |
| 211 | + issues.append("odd quote inside a parenthesized scope at offset %d" % token.start) |
| 212 | + break |
| 213 | + |
| 214 | + sig = _significant(tokens) |
| 215 | + |
| 216 | + for i in range(len(sig)): |
| 217 | + cur = sig[i] |
| 218 | + prev = sig[i - 1] if i > 0 else None |
| 219 | + nxt = sig[i + 1] if i + 1 < len(sig) else None |
| 220 | + |
| 221 | + # a keyword operator immediately followed by '(' is a function call |
| 222 | + # (e.g. the SQLite/MySQL LIKE(a, b) function), not a binary operator |
| 223 | + curIsFunc = cur.type == T_KEYWORD and nxt is not None and nxt.type == T_LPAREN |
| 224 | + curBinary = _isBinary(cur) and not curIsFunc |
| 225 | + |
| 226 | + # -- glued number/keyword boundary: '1UNION', '1AND' --------------- |
| 227 | + if cur.type == T_NUM and nxt is not None and nxt.start == cur.end and nxt.type in (T_IDENT, T_KEYWORD): |
| 228 | + issues.append("digit glued to a word ('%s%s') at offset %d" % (cur.value, nxt.value, cur.start)) |
| 229 | + |
| 230 | + # -- operand directly followed by a bare number: 'id 1' ------------ |
| 231 | + # a numeric literal can never be an alias, so this is always broken |
| 232 | + if cur.type == T_NUM and prev is not None and prev.type in _HARD_OPERANDS: |
| 233 | + issues.append("missing separator before number '%s' at offset %d" % (cur.value, cur.start)) |
| 234 | + |
| 235 | + # -- degenerate parenthesis / punctuation adjacency ---------------- |
| 236 | + if prev is not None: |
| 237 | + pair = (prev.type, cur.type) |
| 238 | + if pair == (T_COMMA, T_COMMA): |
| 239 | + issues.append("empty list item (',,') at offset %d" % cur.start) |
| 240 | + elif pair == (T_LPAREN, T_COMMA): |
| 241 | + issues.append("comma right after '(' at offset %d" % cur.start) |
| 242 | + elif pair == (T_COMMA, T_RPAREN): |
| 243 | + issues.append("comma right before ')' at offset %d" % cur.start) |
| 244 | + elif pair == (T_RPAREN, T_LPAREN): |
| 245 | + issues.append("adjacent groups ')(' at offset %d" % cur.start) |
| 246 | + elif pair == (T_LPAREN, T_RPAREN) and (i < 2 or sig[i - 2].type in (T_OP, T_COMMA, T_LPAREN)): |
| 247 | + issues.append("empty parentheses at offset %d" % prev.start) |
| 248 | + elif cur.type == T_RPAREN and _isBinary(prev): |
| 249 | + issues.append("operator right before ')' at offset %d" % cur.start) |
| 250 | + elif prev.type == T_COMMA and curBinary: |
| 251 | + issues.append("operator right after ',' at offset %d" % cur.start) |
| 252 | + elif prev.type == T_LPAREN and curBinary: |
| 253 | + issues.append("operator right after '(' at offset %d" % cur.start) |
| 254 | + |
| 255 | + # -- doubled binary operators: '= =', 'AND AND' -------------------- |
| 256 | + if prev is not None and _isBinary(prev) and curBinary: |
| 257 | + # allow a unary that legitimately follows (handled by NOT/~/sign) |
| 258 | + if not (cur.type == T_KEYWORD and cur.value.upper() == "NOT"): |
| 259 | + issues.append("doubled operator ('%s %s') at offset %d" % (prev.value, cur.value, prev.start)) |
| 260 | + |
| 261 | + # -- stray un-lexable character ------------------------------------ |
| 262 | + if cur.type == T_OTHER: |
| 263 | + issues.append("stray character '%s' at offset %d" % (cur.value, cur.start)) |
| 264 | + |
| 265 | + return issues |
0 commit comments