Skip to content

feat: -mode encode-marked, and optional MySQL executable-comment lexing - #3

Open
blotus wants to merge 5 commits into
mainfrom
feat/encode-marked
Open

feat: -mode encode-marked, and optional MySQL executable-comment lexing#3
blotus wants to merge 5 commits into
mainfrom
feat/encode-marked

Conversation

@blotus

@blotus blotus commented Aug 21, 2026

Copy link
Copy Markdown
Member

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.

  1. -mode encode-marked, which keeps quote positions (cmd/sqlprocessor).
  2. WithExecutableComments, which lexes MySQL executable comment bodies as SQL (lexer).

1. -mode encode-marked

Why

encode deletes 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 one INCOMPLETE_STRING, and the whole sample loses its structure.

But deleting them also erases the shape of the commonest injection there is. After the strip:

"anything' OR 'x'='x"   ->  IDENT SPACE KEYWORD SPACE IDENT OPERATOR IDENT
"anything or x=x"       ->  IDENT SPACE KEYWORD SPACE IDENT OPERATOR IDENT

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-marked splits the line at each quote, lexes each segment separately, and emits a QUOTE token between them:

"anything' OR 'x'='x"   ->  IDENT QUOTE SPACE KEYWORD SPACE QUOTE IDENT QUOTE OPERATOR QUOTE IDENT
"O'Brien"               ->  IDENT QUOTE IDENT
"'-'"                   ->  QUOTE OPERATOR QUOTE
"'; DROP TABLE users; --"  ->  QUOTE PUNCTUATION SPACE COMMAND SPACE KEYWORD SPACE IDENT PUNCTUATION SPACE COMMENT

A quote still never reaches the lexer, so the swallowing problem stays fixed. QUOTE is 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.

readLine now takes a stripQuotes flag instead of always stripping, since this mode needs the quotes the other deletes. Every other caller passes true.

Two behaviours worth reviewing

  • This is a different encoding, not a refinement. 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 — they are not mix-and-match.
  • Quote-only lines stop being skipped. readLine strips before processReader's blank check, so "'" currently arrives empty and is dropped. Under encode-marked it is not empty and encodes as QUOTE. 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, the stripQuotes flag, and the property that no quote ever reaches the lexer (no STRING/INCOMPLETE_STRING/QUOTED_IDENT in 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 encode missed quoted tautologies that a rule-based detector caught. Retraining on encode-marked closed 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. WithExecutableComments

MySQL executes the body of /*! ... */ and /*!NNNNN ... */ when the server is at least version NNNNN. So this is not a comment:

SELECT * FROM t WHERE id=1/*!50000union select pw from users*/

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:

NUMBER MULTILINE_COMMENT

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 select also 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:

NUMBER KEYWORD SPACE COMMAND SPACE IDENT SPACE KEYWORD SPACE IDENT

which is what the server executes.

Details worth reviewing

  • Off by default, deliberately. Normalization wants a MySQL optimizer hint (/*!40101 SET NAMES utf8 */) to stay a comment; enabling this would move obfuscated output for existing callers. sqlprocessor gains -executable-comments, also off, because the encodings it emits are what downstream models were fitted to — enable and retrain together.
  • A version gate is exactly five digits. 50000 is 5.0.0. Shorter runs are not gates, so /*!500or 1=1*/ keeps 500 in the body, where it lexes as a NUMBER.
  • A */ 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 fixes 1/*!50000select '*/' */, which the current path mis-lexes into an INCOMPLETE_STRING.
  • No recursion. The delimiters emit no token, so scanning has to continue past them. Scan does 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.
  • The disabled path is unchanged in cost. A benchmark A/B is inside the noise floor — measured by comparing the base branch against itself, which swings −4.9% to +5.3% on this machine, wider than any delta the change shows.
  • Interaction with 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 a sqlprocessor test 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

blotus and others added 2 commits August 21, 2026 17:57
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>
@blotus blotus changed the title feat(sqlprocessor): add -mode encode-marked, keeping quote positions feat: -mode encode-marked, and optional MySQL executable-comment lexing Aug 21, 2026
… 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>
blotus and others added 2 commits August 21, 2026 23:07
…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
blotus force-pushed the feat/encode-marked branch from b4e50b6 to ff8edd9 Compare August 21, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant