Skip to content

fix(http-honeypot): a semicolon hides the payload from four classifiers (#3364) - #3483

Merged
Xore merged 2 commits into
mainfrom
oc/3364-roundcube-research
Sep 29, 2026
Merged

Xore merged 2 commits into
mainfrom
oc/3364-roundcube-research

Conversation

@Xore

@Xore Xore commented Sep 29, 2026

Copy link
Copy Markdown
Owner

Closes #3364.

Two parts: the missing research document, and a root-cause parser fix the research exposed.

The bug

Since Go 1.17, url.ParseQuery rejects every pair containing a ; and drops it — but returns a non-nil map of the pairs that had none. Four call sites guarded with:

values, err := url.ParseQuery(raw)
if err != nil && len(values) == 0 { continue }

The guard does not fire (there are values left), so parsing continues on the truncated map with the attacker's pair simply absent. The thing the guard was written to tolerate — a body that is not a parameter list at all — is exactly what makes the miss invisible: there is no error to notice.

This matters because the targets are PHP, whose only separator is &. A ; in a value is ordinary data that reaches the target intact while the sensor drops the parameter carrying it. Confirmed end-to-end in the red test: the request body reaches the event (_user=admin%5C%27;or+1%3D1--) but arrives unclassified.

The fix

One root cause, four sites — all four swapped to the existing formValues helper (classify_wordpress.go, added by #3449 for exactly this reason). No new parser, no new file, no new abstraction:

  • classify_roundcube.go, classify_odata.go, classify_teamcity.go, laundering.go

Each site carries a comment explaining why formValues and not url.ParseQuery, not just that the name changed.

Proof

I re-ran the red check independently: restored all four call sites to main's url.ParseQuery version, ran the new test, and got 7 failures, then restored. It is green on the branch.

The author also mutation-checked it: dropping formUnescape's undecodable-side case → 2 tests fail; re-introducing ; as a separator → 5 fail, including a pre-existing #3447 one; reverting each productive call site individually → each caught.

gofmt -l clean, go vet clean, full package ok with -count=1 (uncached). classify_order_3464_test.go passes unmodified and is not in the diff.

The one deliberate behaviour change — review this first

Three existing assertions moved, all on one fixture, $filter=Year%zz%2520eq, and not because of the semicolon.

url.ParseQuery returns an empty map on that broken escape, so the guard fired and the pair was skipped. formValues keeps the undecodable side, so the genuine %25 — a % that decodes twice — is now visible, and the target reads Year%zz%20eq. The old empty classification was the parser deleting its own evidence, not the gate declining.

The rule is unchanged: a malformed escape alone is still not a residual escape. That is now pinned separately on $filter=Year%zz%2Gz, a value whose only escape is broken. What changed is which requests reach the rule. The old test was asserting the parser's behaviour rather than the sensor's rule; that distinction is now two rows instead of one blurred row, justified at the pin in both affected test files.

This is a real change in what gets labelled. It is a widening — more requests qualify — and the reasoning is sound, but it is the thing to check if you disagree with the call.

Docs

docs/research/3364-roundcube-sqli.md carries the unmeasured-coverage caveat forward rather than papering over it: 0 claims against #1888's pinned fixture, 9/9 published shapes, explicitly labelled a fixture and not the fleet. No CVSS, build, or date asserted beyond the issue body and the class's own comment; AV26-503 is cited as second-hand because it was not re-fetched.

Author and committer both Xore <Xore@users.noreply.github.com>, no AI attribution trailers.

@github-actions

Copy link
Copy Markdown

Dependency Review

✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.

Scanned Files

None

…asion under it

The research document the issue asked for, and a root-cause parser fix that
came out of writing it. The detector itself is not new: #3420 added the class,
#3441 closed its case-folding gap, #3464 gave it a file. The issue's STATUS
block ("implementation PRs are OPEN") is stale.

docs/research/3364-roundcube-sqli.md documents the CVE as implemented, citing
file:line, and carries forward the honest caveat from the source: coverage is
NOT measured against the fleet corpus, because the honeypot-v2-* indices are
not reachable from a branch. What is measured is #1888's pinned 30-day
fixture, mirrored in roundcube_coverage_3364_test.go: 0 claims, 9/9 published
shapes. No CVSS, build, or date is asserted beyond what the issue body and the
class's own comment carry; no Kibana field names are invented.

THE FIX. Four cases parsed parameters with url.ParseQuery behind

    if err != nil && len(values) == 0 { continue }

Since Go 1.17 that parser rejects and drops any pair containing a semicolon,
returning a map of the pairs that had none -- so the guard does not fire,
parsing continues on a truncated map, and the attacker's parameter is simply
absent:

    q="_user=x&;_action=login%27+OR+1%3D1--"
      err=invalid semicolon separator in query
      url.Values{"_user":[]string{"x"}}

The targets are PHP, whose only separator is "&", so the payload reaches
Roundcube in full while the sensor cannot see it. The guard's other silent
case is the same bug: a value that will not decode makes ParseQuery return an
empty map, the guard fires, and a deliberately broken escape (%zz) deletes the
sensor's own evidence.

The parser this needs already exists. classify_wordpress.go's formValues
(#3449) splits on &, then the first =, unescapes each side, and keeps an
undecodable side as-is rather than dropping it. All four call sites now use
it -- classify_roundcube.go, classify_odata.go, classify_teamcity.go,
laundering.go -- so this is one root cause retired rather than four bugs left.
No new parser, no new abstraction, no new file; no deps, env vars, routes or
ports. Surrounding logic and bounds are unchanged, and the 64 KiB body cap
upstream of ServeHTTP still holds.

BEHAVIOUR CHANGE, stated: a `;` in a value no longer makes the parameter
invisible, and an undecodable value is no longer discarded. No false-positive
surface is widened where it matters -- `;_action` is still not `_action`,
`;$filter` is still not an OData system option, a semicolon with no payload
behind it stays unlabelled, and qualifyingODataRequest's value-consistency
gate is untouched and still runs on the value it is shown.

One existing expectation changed: `$filter=Year%zz%2520eq` was unlabelled and
is now odata-double-encode-probe. The old answer was url.ParseQuery deleting
the pair on the %zz, not the gate declining -- the value carries a real %25,
which is a % that decodes twice, and the target reads `Year%zz%20eq`. The rule
is unchanged (a malformed escape is still not a residual escape, now pinned
separately on a value whose only escape is broken); which requests reach the
rule did change. Justified at the pin in odata_double_encode_test.go and in
scanner_laundering_3430_test.go.

classify_order_3464_test.go, the deliberate dispatch-order gate, passes
unmodified and is not in this diff.

PROOF. form_values_semicolon_3364_test.go fails on unmodified main (7 failing
subtests, recorded before any source change) and passes after. It drives
classifyPayload, qualifyingODataRequest and the real two-request laundering
state, plus one ServeHTTP end-to-end for the event. Mutation-checked by
watching each go red: dropping formUnescape's undecodable-side case (2 tests),
re-introducing ';' as a separator in formValues (5 tests, including a
pre-existing #3447 one), and reverting each of the three productive call sites
individually. The teamcity site is pinned as gaining nothing, measured rather
than assumed: its call name is a whole-string test, so the target's parser
reaches the same verdict.

Baseline -> final, same package: 129 top-level / 293 subtests -> 134 / 311,
0 fail, 0 skip, no new xfail, no weakened assertion. gofmt -l clean, go vet
clean. tests/docs: 659 passed, 1 xfailed, 17 subtests, before and after.
@Xore
Xore force-pushed the oc/3364-roundcube-research branch from 34fcd7f to 1c47d70 Compare September 29, 2026 09:14
@Xore
Xore merged commit e4d7c5b into main Sep 29, 2026
283 of 317 checks passed
@Xore
Xore deleted the oc/3364-roundcube-research branch September 29, 2026 10:40
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.

research: CVE-2026-48842 Roundcube pre-auth SQLi (virtuser_query) actively exploited — APIARY HTTP payload-classification coverage

1 participant