package dibaguard import ( "regexp" "sort" "strings" "unicode" "unicode/utf8" ) // The hard part of A22 - the SQL-context tier (dibaguard/http/sqlctx.py, judged by dibaguard/shield/database.py // classify) - inline in the fast path (V-09): every parameter of the canonical text is placed in the query contexts // applications build (numeric, string, double-quoted, parenthesised, LIKE) and a value that breaks out of one into an // attack's shape is a hard hit, refused inline like a payload signature. The learned second opinion (DbNet) and the // soft hits (a request too large to judge) stay with the sidecar, which is sent the same text. // // Only the families A22 acts on (injection, rce, privilege, destructive) are ported. Branches anchored at the start of // the query (^\s*CONFIG SET ..., ^\s*DELETE FROM ...) can never match a query that starts with the context's SELECT and // are left out; the two back-references (or x = x; "$eq": [n, n]) and the look-aheads are code. Python's \d and \w are // Unicode-aware and so are the Go classes (\p{Nd}, \p{L}\p{N}_); RE2's \b is ASCII-only, a divergence only for a SQL // word glued to a non-ASCII letter. Parity: sqlctx_parity in canon_parity.json (TestSQLContextParity). const ( sqlWindow = 960 // sqlctx.WINDOW sqlWindowOverlap = 256 // sqlctx.WINDOW_OVERLAP sqlBudget = 6144 // sqlctx.BUDGET ) // pyRx compiles a Python pattern for RE2: \d and \w become their Unicode classes (Python's str patterns are Unicode). func pyRx(flags, p string) *regexp.Regexp { var b strings.Builder inClass := false for i := 0; i < len(p); i++ { c := p[i] switch { case c == '\\' && i+1 < len(p): n := p[i+1] i++ switch { case n == 'd': b.WriteString(`\p{Nd}`) case n == 'w' && inClass: b.WriteString(`\p{L}\p{N}_`) case n == 'w': b.WriteString(`[\p{L}\p{N}_]`) default: b.WriteByte('\\') b.WriteByte(n) } continue case c == '[' && !inClass: inClass = true b.WriteByte(c) if i+1 < len(p) && p[i+1] == '^' { b.WriteByte('^') i++ } if i+1 < len(p) && p[i+1] == ']' { // a ']' first in a class is a literal b.WriteByte(']') i++ } continue case c == ']' && inClass: inClass = false } b.WriteByte(c) } return regexp.MustCompile(flags + b.String()) } var ( sqlContexts = []struct{ name, tpl string }{ {"numeric", "SELECT a FROM t WHERE id = {v}"}, {"string", "SELECT a FROM t WHERE s = '{v}'"}, {"dquote", "SELECT a FROM t WHERE s = \"{v}\""}, {"paren_num", "SELECT a FROM t WHERE (id = {v})"}, {"paren_str", "SELECT a FROM t WHERE (s = '{v}')"}, {"like", "SELECT a FROM t WHERE s LIKE '%{v}%'"}, } sqlSkipHeaders = map[string]bool{"h:accept": true, "h:accept-encoding": true, "h:content-type": true, "h:cache-control": true, "h:content-length": true, "h:connection": true} // sqlctx._PRE / _GATE with Python's Unicode word boundaries: a word must not touch a letter, digit or '_' sqlPre = regexp.MustCompile("(?i)['\"`;()#=<>|&*\\\\]|--|/\\*|(?:^|[^\\p{L}\\p{N}_])(?:select|union|sleep|benchmark|waitfor|or|and|xor|having|order|group|exec|declare|" + "extractvalue|updatexml|pg_sleep|information_schema|concat|char|chr|cast|convert|drop|insert|delete|update|like|rlike|regexp)(?:$|[^\\p{L}\\p{N}_])") sqlGate = regexp.MustCompile("(?i)['\"`#]|--|/\\*|(?:^|[^\\p{L}\\p{N}_])(?:(?:select|union|sleep|benchmark|waitfor|pg_sleep|having|exec|execute|declare|information_schema|" + "extractvalue|updatexml|dual|concat|char|chr|rlike|regexp)(?:$|[^\\p{L}\\p{N}_])|xp_[\\p{L}\\p{N}_]+|(?:or|and|xor)(?:\\s+[\\p{Nd}'\"(@]|['\"(@]))|&&|\\|\\|") sqlStrongWords = []string{"union", "select", "sleep", "benchmark", "or", "and", "xor", "from", "where", "exec", "waitfor", "extractvalue", "updatexml"} // database.SIGS / RAW for the families A22 acts on (see the file comment for what is left out and why) dbInjection = pyRx("(?is)", `(?:\bor|\|\|)\s*\(?\s*(\d+|'s'|0x[0-9a-f]+)\s*(=|like|>|<)\s*(\d+|'s'|0x[0-9a-f]+)`+ `|\bor\s+(true|not\s+0)\b|\(\s*select\s+(cast|convert)\s*\(`+ `|(?:\bor|\band|\bxor|\|\||&&)\s*\(?\s*\d+\s*[-*+/]\s*\d+\s*=\s*\d+`+ `|=\s*\(\s*select\s*[\d(]`+ `|\b(and|or|xor|&&)\s+\d+\s*[=<>]+\s*\d+\s*(--|#|;|$)`+ `|\band\s+'s'\s*=\s*'s'\s*;?\s*$`+ `|(?:\bor|\bxor|\|\|)\s*\(?\s*(-?\d+|'s'|true|false|null)\s+(not\s+)?(between\s+\S+\s+and\s+\S+|in\s*\(|is\s+(not\s+)?(true|false|null|unknown)\b`+ `|r?like\s|regexp\s|sounds\s+like\s|div\s|mod\s|<=>)`+ `|(?:\bor|\bxor|\|\|)\s+(-?[1-9]\d*|true|'s')\s*(;|--|#|\)|$)|\bxor\s+(-?\d+|'s'|true)\b`+ `|(?:\bor|\bxor)\s+(greatest|least|if|ifnull|coalesce|abs|sign|length|ascii|char|concat)\s*\(\s*(-?\d+|'s')\s*(,\s*(-?\d+|'s')\s*)*\)`+ `|;\s*(and|or|xor)\s`+ `|\bunion(\s+all|\s+distinct)?\s+select\s+(null\b|\d|0x|concat|char\s*\(|@@|version\s*\(|user\s*\(|database\s*\(|group_concat|'s')`+ `|;\s*(drop|truncate|delete|insert|update|create|alter|exec|execute|shutdown|grant|copy|declare|waitfor)\b|\b(pg_sleep|sleep|benchmark|randomblob)\s*\(`+ `|\bwaitfor\s+delay\b|\bdbms_(pipe\.receive_message|lock\.sleep)\b|\b(extractvalue|updatexml)\s*\(|\bcast\s*\(\s*\(\s*select\b`+ `|\bconvert\s*\(\s*int\s*,\s*\(\s*select\b|\bexp\s*\(\s*~|\bfloor\s*\(\s*rand\s*\(|\b(order|group)\s+by\s+(\d+\s*,\s*){0,9}\d+\s*(--|#)`+ `|\b(ascii|substring|substr|mid|ord)\s*\(\s*\(\s*select\b|\bif\s*\(\s*\d+\s*=\s*\d+\s*,\s*sleep`+ `|\b(ascii|substring|substr|mid|ord|length)\s*\(\s*(@@\w+|(database|user|current_user|version|schema)\s*\(\s*\))|\border\s+by\s+\d+\s+order\s+by\b`+ `|\bunion(\s+all|\s+distinct)?\s+select\b[^;]*\b(from\s+(information_schema|mysql\.|pg_|sys\.|master\.|users|members|accounts|auth_user)`+ `|password|passwd|authentication_string|pass\b)|\bunion(\s+all|\s+distinct)?\s+select\b[^;]*(--|#)\s*-?\s*$`) dbRCE = pyRx("(?is)", `\blo_(import|export)\s*\(|\bpg_(read_file|read_binary_file|ls_dir|write_file|stat_file)\s*\(`+ `|\bxp_(cmdshell|dirtree|fileexist)\b`+ `|\bsp_oa(create|method)\b|\bopen(rowset|datasource)\s*\(|\bsys_(exec|eval)\s*\(|\bload\s+data\s+(local\s+)?infile\b|\bsoname\b`+ `|\binto\s+(out|dump)file\b|\bload_file\s*\(|\bdbms_(java|scheduler)\b|\butl_(http|file|tcp|inaddr)\b|\bhttpuritype\b|\$where\b|\$function\b`+ `|\$accumulator\b|\bmapreduce\b|\brunprogram\s*\(`+ `|redis\.p?call\s*\(\s*['"](config|slaveof|replicaof|module|debug)['"]`+ `|\bpackage\.loadlib\b|\bdblink(_exec|_connect)?\s*\(`+ `|\bjava\.lang\.|\bgetruntime\s*\(|\bapoc\.(load|cypher\.run\w*|import)\w*|\binsert\s+into\s+function\b|\bfrom\s+(url|file|s3|hdfs)\s*\(`+ `|\bcopy\b.*\b(to|from)\s+program\b|\bcreate\s+(or\s+replace\s+)?function\b.*\blanguage\s+(c|plpythonu|plpython3u|plperlu|plsh|java|javascript)\b`+ `|\bcreate\s+extension\b.*\b(dblink|plpython\w*|adminpack)\b`) dbPrivilege = pyRx("(?is)", `\bgrant\s+all\b|\bset\s+global\b|\bsp_addsrvrolemember\b`+ `|\balter\s+server\s+role\s+sysadmin\b|\bupdate\s+(pg_authid|mysql\.user)\b|\bset\s+role\s+postgres\b|\balter\s+system\s+set\b`+ `|\bgrant\s+all\s+permissions\b|redis\.p?call\s*\(\s*['"]acl['"]\s*,\s*['"]setuser`+ `|/_security/(user|role)/\S+\s*\{|\bdbms\.security\.createuser\b|\bgrant\s+role\s+admin\b|\bcreate\s+login\b`+ `|\balter\s+(user|role)\b.*\b(superuser|createrole|bypassrls|replication)\b|\bgrant\b.*\bto\s+public\b|\bcreate\s+(user|role)\b.*\bsuperuser\b`+ `|\bgrant\b.*\bwith\s+grant\s+option\b|\b(createuser|grantrolestouser|updateuser)\b.*\b(root|useradminanydatabase|readwriteanydatabase|dbowner|clusteradmin|__system)\b`) dbDestructive = pyRx("(?is)", `\bdrop\s+(database|schema|table|keyspace|collection)\b|\btruncate\b`+ `|\bdropdatabase\b|"drop"\s*:\s*"|\.drop\s*\(\s*\)|\b(deletemany|remove)\s*\(\s*\{\s*\}\s*\)|"deletes"\s*:\s*\[\s*\{\s*"q"\s*:\s*\{\s*\}`+ `|redis\.p?call\s*\(\s*['"](flushall|flushdb|shutdown)['"]`+ `|\bmatch\s*\(\s*\w+\s*\)\s*(detach\s+)?delete\b`+ `|"q"\s*:\s*\{\s*\}\s*,\s*"u"\s*:.*"multi"\s*:\s*true|_delete_by_query.*match_all`) dbRawRCE = pyRx("(?is)", `\bsp_configure\s+'(xp_cmdshell|ole automation procedures)'|\bload\s+csv\s+from\s+'file:|\b(url|file|s3)\s*\(\s*'(https?://(169\.254|metadata)|/)`+ `|"(script|source|inline|body|map|reduce|init|accumulate)"\s*:\s*"[^"]*(java\.lang|getruntime|runtime\.exec|runprogram|processbuilder)`) dbRawDestructive = pyRx("(?is)", `\b(btc|bitcoin|monero|xmr)\b.*\b(recover|backed\s+up|pay|send)\b|\b(recover|backed\s+up|pay|send)\b.*\b(btc|bitcoin)\b`+ `|\bcreate\s+(table|database)\s+\W?\w*(readme|read_me|recover|warning|please_read)`) dbRawInjection = pyRx("(?is)", `"(pass\w*|pwd|token|secret|api_?key)"\s*:\s*\{\s*"\$(ne|gt|gte|lt|regex|exists|nin|in|not)"|\b(pass\w*|pwd|token)\s*:\s*\{\s*\$(ne|gt|regex|exists)\b`+ `|"\$or"\s*:\s*\[\s*\{\s*\}`+ `|"(user(name)?|login|e-?mail)"\s*:\s*\{\s*"\$(ne"\s*:\s*(null|"")\s*\}`+ `|gte?"\s*:\s*""\s*\}|regex"\s*:\s*"(\^|\.\*))`) dbExprEq = pyRx("(?is)", `"\$expr"\s*:\s*\{\s*"\$eq"\s*:\s*\[\s*(\d+)\s*,\s*(\d+)\s*\]`) // "$eq": [n, n] (a back-reference in Python) dbOrSelf = regexp.MustCompile(`(?i)(?:^|[^\p{L}\p{N}_])or|\|\|`) // the start of "or x = x" (a back-reference in Python) dbSQ = regexp.MustCompile(`'(?:[^'\\]|\\.|'')*'`) dbSQStd = regexp.MustCompile(`'(?:[^']|'')*'`) // database._SQ_STD: no backslash escapes (standard SQL) dbVC = pyRx("(?s)", `/\*!\d*\s*(.*?)\*/`) dbCM = regexp.MustCompile(`(?s)/\*.*?\*/`) dbLCHash = regexp.MustCompile(`(?:#|--)[^\r\n]*[\r\n]`) dbTermLit = regexp.MustCompile(`--|#|/\*|;`) dbNeedRCE = strings.Fields("program lo_ pg_ language xp_ sp_ openrowset opendatasource sys_ infile soname outfile dumpfile load_file dbms_ utl_ httpuritype $where $function $accumulator mapreduce runprogram config slaveof replicaof module system.exec loadlib dblink extension java.lang getruntime apoc function url file s3 hdfs redis. script source inline body map reduce init accumulate processbuilder") dbNeedPriv = []string{"alter", "grant", "create", "set global", "sp_addsrvrolemember", "sysadmin", "pg_authid", "mysql.user", "set role", "createuser", "grantrolestouser", "updateuser", "acl", "requirepass", "protected-mode", "_security", "dbms.security"} dbNeedDestr = []string{"drop", "truncate", "delete", "update", "remove", "deletes", "\"q\"", "flushall", "flushdb", "shutdown", "debug", "_delete_by_query", "match", "btc", "bitcoin", "monero", "xmr", "recover", "backed", "readme", "read_me", "warning", "please_read", "redis."} dbNeedInj = []string{" or", "or ", "||", "xor", "and", "&&", "union", ";", "sleep", "benchmark", "randomblob", "waitfor", "dbms_", "extractvalue", "updatexml", "cast", "convert", "exp", "rand", "order", "group", "ascii", "substr", "mid", "ord", "length", "if", "select", "$", "pass", "pwd", "token", "secret"} sqlLeadWords = strings.Fields("or and xor not union select having group order limit procedure into from where like rlike regexp in is between div mod sounds collate case exists glob notnull isnull") sqlStmtWords = strings.Fields("select insert update delete drop create alter exec execute declare waitfor shutdown grant truncate set show call load with begin handler do") ) func isPyWord(r rune) bool { return unicode.IsLetter(r) || unicode.IsNumber(r) || r == '_' } func isASCIIWord(c byte) bool { return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || c == '_' } // hasAny is `any(k in a or k in b for k in need)`. func hasAny(a, b string, need []string) bool { for _, k := range need { if strings.Contains(a, k) || strings.Contains(b, k) { return true } } return false } // pyFoldWS is database._WS.sub(" ", s).strip(). func pyFoldWS(s string) string { var b strings.Builder b.Grow(len(s)) space := false for _, r := range s { if pyIsSpace(r) { space = true continue } if space && b.Len() > 0 { b.WriteByte(' ') } space = false b.WriteRune(r) } return b.String() } // dbViews is database.views(query, "mysql"): (raw, literals stripped, structural). func dbViews(q string) (string, string, string) { raw := q if runeLen(raw) > 1024 { rs := []rune(raw) raw = string(rs[:768]) + " " + string(rs[len(rs)-256:]) } s1 := dbSQ.ReplaceAllString(raw, "'s'") s2 := dbCM.ReplaceAllString(dbVC.ReplaceAllString(s1, " ${1} "), " ") if strings.ContainsAny(s2, "\n\r") { s2 = dbLCHash.ReplaceAllString(s2, " ") } return raw, s1, pyFoldWS(s2) } // orSelf is the back-reference branch `(?:\bor|\|\|)\s+\(?\s*(?P[\w'()]+)\s*=\s*(?P=t1)(?![\w(])`. func orSelf(s string) bool { inT := func(r rune) bool { return isPyWord(r) || r == '\'' || r == '(' || r == ')' } for _, m := range dbOrSelf.FindAllStringIndex(s, -1) { p := m[1] // after "or" / "||" (the boundary character before "or" is part of the match) // \s+ q := p for q < len(s) { r, n := utf8.DecodeRuneInString(s[q:]) if !pyIsSpace(r) { break } q += n } if q == p { continue } starts := []int{q} if q < len(s) && s[q] == '(' { // \(? consumed, then \s* k := q + 1 for k < len(s) { r, n := utf8.DecodeRuneInString(s[k:]) if !pyIsSpace(r) { break } k += n } starts = append(starts, k) } for _, st := range starts { e := st for e < len(s) { r, n := utf8.DecodeRuneInString(s[e:]) if !inT(r) { break } e += n } if e == st { continue } t1 := s[st:e] k := e for k < len(s) { r, n := utf8.DecodeRuneInString(s[k:]) if !pyIsSpace(r) { break } k += n } if k >= len(s) || s[k] != '=' { continue } k++ for k < len(s) { r, n := utf8.DecodeRuneInString(s[k:]) if !pyIsSpace(r) { break } k += n } if len(s)-k < len(t1) || !strings.EqualFold(s[k:k+len(t1)], t1) { continue } k += len(t1) if k < len(s) { if r, _ := utf8.DecodeRuneInString(s[k:]); isPyWord(r) || r == '(' { continue } } return true } } return false } // literalCut is database._literal_cut, the classify fallback for a value placed inside a string literal (V-18): the // query's first literal is the one the payload closed; up to the first comment or ';' after it there must be nothing // (admin'--, x';) or SQL (x' or name is not null--). Prose breaks out with letters ("It's great; ..." -> "s great"). func literalCut(s1 string) bool { i := strings.IndexByte(s1, '\'') if i < 0 || !strings.HasPrefix(s1[i:], "'s'") { // the application's literal never closed: no break-out return false } b := i + 3 lastClose := strings.LastIndex(s1, "*/") for _, m := range dbTermLit.FindAllStringIndex(s1[b:], -1) { ts, te := b+m[0], b+m[1] if s1[ts:te] == "/*" && lastClose >= te { // a closed /* */ cuts nothing: the next one decides continue } return sqlLead(s1[b:ts]) } return false } // sqlLead is `_NOTHING.fullmatch(lead) or _SQL_LEAD.match(lead)`. func sqlLead(lead string) bool { i := 0 for i < len(lead) { r, n := utf8.DecodeRuneInString(lead[i:]) if !pyIsSpace(r) && r != ')' { break } i += n } rest := lead[i:] if rest == "" { return true } word := func(s string, ws []string) bool { low := strings.ToLower(s) for _, w := range ws { if strings.HasPrefix(low, w) && (len(s) == len(w) || !isASCIIWord(s[len(w)])) { return true } } return false } switch { case word(rest, sqlLeadWords), strings.HasPrefix(rest, "||"), strings.HasPrefix(rest, "&&"), strings.IndexByte("=<>!+*/%^~|&,?:@", rest[0]) >= 0: return true case rest[0] == '-': return len(rest) == 1 || rest[1] != '-' case rest[0] == ';': k := 1 for k < len(rest) { r, n := utf8.DecodeRuneInString(rest[k:]) if !pyIsSpace(r) { break } k += n } return word(rest[k:], sqlStmtWords) } return false } // dbHard is classify(q, "mysql", literal) restricted to the families A22 acts on: true when one of them is found. func dbHard(q string, literal bool) bool { raw, s1, s2 := dbViews(q) ls2, lraw := strings.ToLower(s2), strings.ToLower(raw) if hasAny(ls2, lraw, dbNeedRCE) && (dbRCE.MatchString(s2) || dbRawRCE.MatchString(raw)) { return true } if hasAny(ls2, lraw, dbNeedPriv) && dbPrivilege.MatchString(s2) { return true } if hasAny(ls2, lraw, dbNeedDestr) && (dbDestructive.MatchString(s2) || dbRawDestructive.MatchString(raw)) { return true } if hasAny(ls2, lraw, dbNeedInj) { if dbInjection.MatchString(s2) || orSelf(s2) || dbRawInjection.MatchString(raw) { return true } for _, m := range dbExprEq.FindAllStringSubmatch(raw, -1) { if m[1] == m[2] { return true } } } // the break-out as MySQL reads backslashes, and as standard SQL does (x\' closes a PostgreSQL / SQLite literal) return literal && (literalCut(s1) || strings.Contains(raw, "\\'") && literalCut(dbSQStd.ReplaceAllString(raw, "'s'"))) } var swapQuotes = strings.NewReplacer("'", "\"", "\"", "'") // sqlJudge is sqlctx.judge_value without the learned tier: the first context the value breaks out of, or "". func sqlJudge(v string) string { sq := strings.ContainsAny(v, "'\\") dq := strings.ContainsAny(v, "\"\\") for _, c := range sqlContexts { if strings.HasPrefix(c.name, "paren") && !strings.Contains(v, ")") || c.name == "like" && !strings.Contains(v, "%") { continue } if (c.name == "string" || c.name == "paren_str" || c.name == "like") && !sq || c.name == "dquote" && !dq { continue } var q string if c.name == "dquote" { q = strings.Replace(sqlContexts[1].tpl, "{v}", swapQuotes.Replace(v), 1) } else { q = strings.Replace(c.tpl, "{v}", v, 1) } if dbHard(q, c.name == "string" || c.name == "dquote" || c.name == "paren_str" || c.name == "like") { return c.name } } return "" } // unquotePlus is urllib.parse.unquote_plus. func unquotePlus(s string) string { s = strings.ReplaceAll(s, "+", " ") if strings.IndexByte(s, '%') < 0 { return s } b := make([]byte, 0, len(s)) for i := 0; i < len(s); { if s[i] == '%' && i+2 < len(s) && ishex(s[i+1]) && ishex(s[i+2]) { run := make([]byte, 0, 8) for i+2 < len(s) && s[i] == '%' && ishex(s[i+1]) && ishex(s[i+2]) { run = append(run, unhex(s[i+1])<<4|unhex(s[i+2])) i += 3 } b = append(b, validUTF8(run)...) continue } b = append(b, s[i]) i++ } return string(b) } // pctU is sqlctx._PCTU.sub. func pctU(s string) string { if !strings.Contains(s, "%u") { return s } b := make([]byte, 0, len(s)) for i := 0; i < len(s); i++ { if s[i] == '%' && i+5 < len(s) && s[i+1] == 'u' && ishex(s[i+2]) && ishex(s[i+3]) && ishex(s[i+4]) && ishex(s[i+5]) { b = appendRune(b, rune(unhex(s[i+2]))<<12|rune(unhex(s[i+3]))<<8|rune(unhex(s[i+4]))<<4|rune(unhex(s[i+5]))) i += 5 continue } b = append(b, s[i]) } return string(b) } // sqlDecode is sqlctx.decode: the value as the application finally sees it. func sqlDecode(v string) string { for i := 0; i < 3; i++ { d := v if strings.ContainsAny(v, "%+") { d = unquotePlus(v) } d = pctU(d) if d == v { break } v = d } if strings.IndexByte(v, '&') >= 0 { v = pyUnescapeHTML(v) } if strings.IndexByte(v, '\\') >= 0 { // \xHH first, then \uXXXX (the reverse of canon's order) v = escDecodeOrder(v, []byte{'x', 'u'}) } return strings.ReplaceAll(v, "\x00", "") } // escDecodeOrder runs one left-to-right pass per escape kind, in the given order. func escDecodeOrder(s string, order []byte) string { for _, c := range order { n := 2 if c == 'u' { n = 4 } var b []byte last := 0 for i := 0; i+1+n < len(s); i++ { if s[i] != '\\' || s[i+1] != c { continue } ok := true for k := 0; k < n; k++ { if !ishex(s[i+2+k]) { ok = false break } } if !ok { continue } v := rune(0) for k := 0; k < n; k++ { v = v<<4 | rune(unhex(s[i+2+k])) } b = append(b, s[last:i]...) b = appendRune(b, v) i += 1 + n last = i + 1 } if b != nil { s = string(append(b, s[last:]...)) } } return s } type sqlField struct{ name, v string } // sqlFields is sqlctx.fields. func sqlFields(text string) []sqlField { parts := strings.Split(text, " | ") var out []sqlField _, rest, two := strings.Cut(parts[0], " ") if two && strings.Contains(rest, "?") { _, q, _ := strings.Cut(rest, "?") for _, kv := range strings.Split(q, "&") { k, v, _ := strings.Cut(kv, "=") out = append(out, sqlField{"q:" + k, sqlDecode(v)}) } } if two { path, _, _ := strings.Cut(rest, "?") for _, seg := range strings.Split(path, "/") { if seg != "" && sqlPre.MatchString(seg) { out = append(out, sqlField{"path", sqlDecode(seg)}) } } } for _, p := range parts[1:] { if runeLen(p) < 3 { continue } _, n0 := utf8.DecodeRuneInString(p) if r1, _ := utf8.DecodeRuneInString(p[n0:]); r1 != ':' { continue } k, v, _ := strings.Cut(p, "=") if strings.HasPrefix(p, "h:sec-") || sqlSkipHeaders[k] { continue } if v != "" { out = append(out, sqlField{k, sqlDecode(v)}) } name := sqlDecode(k[n0+1:]) if sqlPre.MatchString(name) && strings.ContainsAny(name, " '\"(#") { out = append(out, sqlField{k + "#key", name}, sqlField{k + "#part", sqlDecode(p[n0+1:])}) } } return out } // sqlGated is sqlctx.gated: contraction apostrophes (it's, don't, I'm, we'll, they're, I've, I'd) do not count. func sqlGated(v string) bool { if strings.IndexByte(v, '\'') >= 0 { var b strings.Builder for i := 0; i < len(v); i++ { if v[i] == '\'' && i > 0 && isLetterASCII(v[i-1]) && contractionAt(v[i+1:]) { continue } b.WriteByte(v[i]) } v = b.String() } return sqlGate.MatchString(v) } func isLetterASCII(c byte) bool { return 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' } func contractionAt(s string) bool { for _, suf := range []string{"s", "t", "m", "d", "ll", "re", "ve"} { if strings.HasPrefix(s, suf) && (len(s) == len(suf) || !isASCIIWord(s[len(suf)])) { return true } } return false } // strongCount is len(sqlctx._STRONG.findall(s)) (case-insensitive, Unicode word boundaries). func strongCount(s string) int { n := 0 prevWord := false for i := 0; i < len(s); { c := s[i] switch { case c == '\'' || c == '"' || c == '`' || c == '#' || c == ';': n++ i++ prevWord = false continue case strings.HasPrefix(s[i:], "--") || strings.HasPrefix(s[i:], "/*"): n++ i += 2 prevWord = false continue } if !prevWord { low := strings.ToLower(s[i:minInt(len(s), i+16)]) matched := 0 for _, w := range sqlStrongWords { if strings.HasPrefix(low, w) { if j := i + len(w); j >= len(s) || func() bool { r, _ := utf8.DecodeRuneInString(s[j:]); return !isPyWord(r) }() { matched = len(w) break } } } if matched > 0 { n++ i += matched prevWord = true continue } } r, w := utf8.DecodeRuneInString(s[i:]) prevWord = isPyWord(r) i += w } return n } func minInt(a, b int) int { if a < b { return a } return b } // sqlWindows is sqlctx.windows (code points). func sqlWindows(v string) []string { n := runeLen(v) if n <= sqlWindow { return []string{v} } rs := []rune(v) step := sqlWindow - sqlWindowOverlap var out []string for i := 0; i < n-sqlWindowOverlap; i += step { e := i + sqlWindow if e > n { e = n } out = append(out, string(rs[i:e])) } return out } // SQLHit is a hard A22 hit: the field, the context it breaks out of, the window judged. type SQLHit struct { Field, Context, Value string } // SQLContext is the hard part of sqlctx.analyse on a canonical request text: the first field whose value breaks out // of a query context into an attack's shape, within the same budget and in the same order as the Python tier. func SQLContext(text string) *SQLHit { var cand []sqlField for _, f := range sqlFields(text) { if runeLen(f.v) >= 2 && sqlGated(f.v) { cand = append(cand, f) } } if len(cand) > 1 { key := make([]int, len(cand)) for i, f := range cand { key[i] = strongCount(runeCut(f.v, sqlWindow)) } idx := make([]int, len(cand)) for i := range idx { idx[i] = i } sort.SliceStable(idx, func(a, b int) bool { return key[idx[a]] > key[idx[b]] }) c2 := make([]sqlField, len(cand)) for i, j := range idx { c2[i] = cand[j] } cand = c2 } budget := sqlBudget for _, f := range cand { for _, w := range sqlWindows(f.v) { wl := runeLen(w) if wl < 2 || !sqlGated(w) { continue } if wl > budget { return nil // the budget ran out: a soft hit, the sidecar's to answer } budget -= wl if ctx := sqlJudge(w); ctx != "" { return &SQLHit{Field: f.name, Context: ctx, Value: runeCut(w, 200)} } } } return nil }