feat: -mode encode-marked, and optional MySQL executable-comment lexing - #3
Open
blotus wants to merge 5 commits into
Open
feat: -mode encode-marked, and optional MySQL executable-comment lexing#3blotus wants to merge 5 commits into
blotus wants to merge 5 commits into
Conversation
The "encode" contract deletes quotes before lexing. That is what stops a
dangling quote swallowing the rest of the input, but it also erases the shape of
the commonest SQL injection there is. After the strip,
anything' OR 'x'='x
anything or x=x
are the same token sequence, so nothing downstream can tell an attack from an
ordinary phrase.
encode-marked splits the line at each quote, lexes each segment separately, and
emits a QUOTE token between them:
IDENT QUOTE SPACE KEYWORD SPACE QUOTE IDENT QUOTE OPERATOR QUOTE IDENT
A quote still never reaches the lexer, so the swallowing problem stays fixed.
readLine takes a stripQuotes flag rather than always stripping, since this mode
needs the quotes the other one deletes. Every other mode passes true and is
unchanged, including the blank-line skip that drops quote-only input -- under
encode-marked such a line is no longer empty and encodes as QUOTE.
This is a different feature encoding, not a refinement of the existing one: a
quote becomes a lexical boundary, so 12'34 is NUMBER QUOTE NUMBER here and a
single NUMBER under the strip. Anything fitted to one encoding cannot consume
the other.
Adds the first tests for cmd/sqlprocessor, covering both encodings, the flag,
and the property that no quote reaches the lexer.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MySQL runs the body of /*! ... */ and /*!NNNNN ... */ when the server is at least version NNNNN, so `id=1/*!50000union select pw from users*/` executes the union on any MySQL >= 5.0. The lexer emits the whole construct as one MULTILINE_COMMENT, so that payload reaches a consumer as NUMBER MULTILINE_COMMENT with the statement hidden inside the comment's value. An encoding built from token types cannot see it at all, and the two tokens it does produce look like a trivial fragment. WithExecutableComments consumes the opening delimiter and its version gate and lexes the body as ordinary SQL, so the token stream matches what the server executes. A version gate is exactly five digits; shorter runs are not gates and stay in the body. A */ inside a string literal does not end the body, matching MySQL, because the body is scanned as SQL rather than searched for a delimiter -- which also fixes the INCOMPLETE_STRING the old path produced for `1/*!50000select '*/' */`. Off by default. Normalization wants a MySQL optimizer hint to stay a comment, and flipping it would move obfuscated output for existing callers. Scan skips the delimiters in a loop rather than recursing, so a run of empty comments does not grow the stack once per delimiter; a benchmark A/B puts the disabled path within the noise floor measured base-against-base. sqlprocessor gains -executable-comments, also off by default since the encodings it emits are what downstream models were fitted to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… boundary The keyword trie only accepts a match when the rune after it is punctuation, space, the start of a comment, or EOF. Inside an executable comment body the rune after the last word is the `*` of the closing `*/`, which is none of those, so the word fell through to the identifier scan. `1 /*!50000or*/ sleep(1)` therefore lexed `or` as IDENT rather than KEYWORD. MySQL executes it as the operator, so the encoding claimed to reproduce what the server runs while quietly disagreeing with it -- and the disagreement landed exactly on the tautology keywords an injection is built from. Caught by retraining on this encoding: the retrained model lost 22.6% of a mutation set the old one caught, because `/*!50000or*/` reached it as an identifier and the KEYWORD n-grams it relies on were gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ect hint Two constructs were degrading to IDENT and OPERATOR, which is the failure mode a token-type consumer cannot see: no error, just a weaker sequence. WAITFOR DELAY '00:00:05' is T-SQL's SLEEP(5), and the oracle a blind injection reads one bit at a time. Neither word was in the keyword list, so both scanned as identifiers -- `sleep(5)` reached consumers as FUNCTION while its SQL Server equivalent reached them as two IDENTs. # was only a comment under WithDBMS(DBMSMySQL). Undeclared it fell through to scanOperator, so `admin'#` -- a login bypass MySQL executes -- scanned as an identifier, a quote and an operator, while `admin' --` scanned as a comment. The same attack, one character apart, and only one of them looked like one. # now opens a comment unless the caller has said something that makes it mean otherwise: - DBMSSQLServer declared: #temp is a temporary table name. Unchanged. - DBMSPostgres declared: #> #>> #- walk a JSON path. Unchanged. - anything else, including no DBMS at all: comment to end of line. Recognising #> from the two characters alone was the tempting version and it is wrong: it leaves `admin'#-` scanning as an operator while MySQL still reads it as a comment, so the bypass survives with one more character. A caller that cannot name its dialect -- a WAF, reading untrusted input with no idea what is behind it -- has to take the reading that assumes the worst. Three tests asserted PostgreSQL JSON operators without declaring PostgreSQL, which is what made the ambiguity look settled. They declare it now; the dbms_test fixtures already did, which is why testdata/postgresql caught this and the unit tests did not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
blotus
force-pushed
the
feat/encode-marked
branch
from
August 21, 2026 23:30
b4e50b6 to
ff8edd9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two changes that make injection structure visible in the token stream, where it is currently dropped. Nothing existing changes — both are opt-in and every default is byte-identical to today.
-mode encode-marked, which keeps quote positions (cmd/sqlprocessor).WithExecutableComments, which lexes MySQL executable comment bodies as SQL (lexer).1.
-mode encode-markedWhy
encodedeletes quotes before lexing. That is the right call and has to stay: a single dangling quote makes the lexer swallow the rest of the input as oneINCOMPLETE_STRING, and the whole sample loses its structure.But deleting them also erases the shape of the commonest injection there is. After the strip:
An attack and an ordinary English phrase produce the identical sequence. Anything consuming this encoding is structurally unable to separate them — it is not a matter of a better consumer, the information is gone before it arrives.
What
encode-markedsplits the line at each quote, lexes each segment separately, and emits aQUOTEtoken between them:A quote still never reaches the lexer, so the swallowing problem stays fixed.
QUOTEis inserted by the encoding, not produced by the lexer; it is upper-case and space-free like every other type name, so it survives a whitespace split unchanged.readLinenow takes astripQuotesflag instead of always stripping, since this mode needs the quotes the other deletes. Every other caller passestrue.Two behaviours worth reviewing
12'34isNUMBER QUOTE NUMBERhere and a singleNUMBERunder the strip. Anything fitted to one encoding cannot consume the other — they are not mix-and-match.readLinestrips beforeprocessReader's blank check, so"'"currently arrives empty and is dropped. Underencode-markedit is not empty and encodes asQUOTE. That is intended, but it means output line numbers differ between the two modes for such inputs.Testing
Adds the first tests for
cmd/sqlprocessor: both encodings, thestripQuotesflag, and the property that no quote ever reaches the lexer (noSTRING/INCOMPLETE_STRING/QUOTED_IDENTin marked output). The existing suite is unchanged and passes.Separately verified against an independent Go reimplementation of this encoding on 154 captured vectors — byte-identical output.
Context
This came out of investigating why a consumer of
encodemissed quoted tautologies that a rule-based detector caught. Retraining onencode-markedclosed most of that gap and improved detection at every false-positive budget we measured. Happy to share the numbers internally; keeping them out of a public PR body.2.
WithExecutableCommentsMySQL executes the body of
/*! ... */and/*!NNNNN ... */when the server is at least versionNNNNN. So this is not a comment:On any MySQL >= 5.0 that runs the union. The lexer emits the whole construct as a single
MULTILINE_COMMENT, so the payload arrives as:The statement is still there, inside the comment token's value, but anything reading token types cannot see it. Worse, the construct that hides a full
union selectalso shrinks the sample to two tokens, so a consumer that treats short fragments as uninteresting will wave it through.With the option enabled the opening delimiter and version gate are consumed and the body is lexed as ordinary SQL:
which is what the server executes.
Details worth reviewing
/*!40101 SET NAMES utf8 */) to stay a comment; enabling this would move obfuscated output for existing callers.sqlprocessorgains-executable-comments, also off, because the encodings it emits are what downstream models were fitted to — enable and retrain together.50000is 5.0.0. Shorter runs are not gates, so/*!500or 1=1*/keeps500in the body, where it lexes as aNUMBER.*/inside a string does not end the body. The body is scanned as SQL rather than searched for a delimiter, so a quoted literal containing*/is consumed as a string — matching MySQL. This incidentally fixes1/*!50000select '*/' */, which the current path mis-lexes into anINCOMPLETE_STRING.Scandoes that in a loop; recursing once per delimiter would grow the stack in proportion to the input, which matters for a lexer reading attacker-controlled bytes. There is a test for 200k empty comments.encode-marked: a quote ends a segment and the lexer scanning it, so an executable comment opened before a quote does not stay open across it. Quote positions are the point of that encoding, so they win. Pinned in a test.Testing
Lexer table tests for both settings: the default single-token behaviour, body lexing with and without a version gate, the sub-five-digit case, plain comments left untouched, the
*/-in-a-string case, an empty comment, an unterminated one, and the 200k-comment stack test. Plus asqlprocessortest covering the flag and the quote-boundary interaction. Existing suite unchanged and passing.Context
Found while checking which mutations evade a token-type encoding. This one is not a near-miss: the payload is dropped by a short-fragment rule because the evasion shrinks it, so it never reaches scoring at all.
🤖 Generated with Claude Code