Skip to content

feat(http-honeypot): measure #3394's Ollure coverage and add the model-target class - #3442

Merged
Xore merged 2 commits into
mainfrom
oc/3394-ollure-attack-classes
Sep 28, 2026
Merged

Xore merged 2 commits into
mainfrom
oc/3394-ollure-attack-classes

Conversation

@Xore

@Xore Xore commented Sep 27, 2026

Copy link
Copy Markdown
Owner

Summary

Part of #3394. OllamaDrama / "Ollure" (arXiv 2609.29757, Elzer/Johansen/Vasilomanolakis) is the first empirical multi-vantage measurement of attacks against exposed self-hosted LLM management APIs: 84 days, four deployments, 290,887 interactions from 2,793 source IPs. The issue marks its coverage gap unmeasured. This measures it and fixes the one real gap the measurement found.

docs/research/3394-ollure-ollama-attack-classes.md is already on main and its §3.4 lays out a proposal. This is the measurement that proposal could not do, and the code change it justifies.

The measurement, as a re-runnable test

The live corpus (honeypot-v2-*) is not reachable from a PR, so ollure_coverage_3394_test.go measures against the corpus that is on main: the pinned real-corpus fixture from payload_test.go (a sample of the fleet's 30-day window, #1888), mirrored entry-for-entry, against 34 request shapes reduced from the paper's own Tables 1–3. Every fixture is inert text. Nothing deserializes a body, instantiates an object, or opens a socket.

paper shapes: 34   as-labelled: 34   unlabelled: 0   mislabelled: 0
issue's naive ungated signature over the same shapes: 5/34
ollama-model-target claims of 27 real-corpus entries: 0

Three results, and the second is the point:

  1. Part of this was already delivered, and the issue does not say so. The paper's Paths category (55 pull + 2 push, 4 unique payloads) lands on the existing path-traversal and secret-read cases, and its mining payloads land on downloader — which is the more useful of the two labels, since the body genuinely is a fetch-and-execute chain. That work is done; it should not be re-implemented. The fixture records which class each shape lands on, so the coverage stays recorded rather than re-derived.

  2. The issue's proposed signature matches 5 of 34, and cannot reach the 9 it was aimed at. Its alternation is anchored with ^, and an Ollama body is JSON — so ^(https?://) and ^127. never see the model name at all. Reproduced unscoped in the test, to show what the gate is worth.

  3. The real gap is the SSRF primitive: a model name used as a network target. New honeypot.payload_class value ollama-model-target, firing on the value of a "name" or "model" JSON string field that names a URL, an out-of-band-interaction host, or a bare host:port. That last is the insecure-registry form (127.0.0.1:37987/cve-12886:latest), which carries no scheme and so is invisible to a ^https?:// rule by construction.

    Anchoring is what makes it usable: the host part must be an address, so llama3.1:70b, meta-llama/llama3.1:70b-instruct and huihui_ai/gemma-4-abliterated:12b stay unlabelled. It is placed after path-traversal and secret-read so those keep the classes they already have.

Also an ollama-api path category in classify(). The paper's own summary is that 79.36% of its interactions went to model- and service-information endpoints, and /api/tags — the largest single one at 102,795 — had no entry at all: it fell to scan, indistinguishable from a web port rake. It is its own category rather than a branch of llm-api, because the paper measures the two dialects behaving differently (102,795 requests / 979 IPs vs 60,949 / 203), and which one a scanner has picked is the thing worth counting. Exact match against a fixed list, not a prefix: /api/v1/ and /apis/ are the Kubernetes cases matched ahead of it, and a bare /api/ prefix would swallow every other decoy route that starts /api.

Deliberately not added

The template-field persisted injection stays unlabelled, and is pinned as a known gap by a test that fails if it ever starts matching. docs/research/3394-*.md §3.4 measures that class at 3 requests in 84 days across four deployments. A rule that fires three times in twelve weeks reads as a broken detector, so closing it is a scope decision, not a drift. The fixture's want is "" with the reasoning inline.

Issues

Refs #3394. The research question is answered and the coverage is measured; the remaining open item is the fidelity question (#3394's own low-priority item) and the galah prompt-injection follow-on in §4 of the research note, which is a design decision for its own review and is not touched here.

Security impact

  • No real credentials, private addresses, payloads, PCAPs, keys, or .env files were added. (Addresses are RFC 5737 documentation ranges, hostnames are RFC 2606 reserved. The wallet address in the fixture is synthetic.)
  • Sandbox/network-isolation implications were reviewed. (Text matching over a string already in memory and already capped at 64 KiB by ServeHTTP. No parsing, no I/O, no deserialization, no evaluation, no outbound request.)
  • Publicly exposed ports and routes are unchanged. (This adds two classification values; no new route is served and no existing one changes answer.)

Validation

  • go vet ./... and go test ./... in http-honeypot: pass — 167 tests, 0 fail (163 before, +4 new). No existing test edited, weakened, or skipped.
  • gofmt clean.
  • New: TestOllure3394CorpusMeasurement (asserts the 9/9 model-target count, zero mislabels, zero false positives, and the deliberate template gap), TestOllure3394PathClass (12 natives + the /api/v1/, /apis/, /api/whoami prefix boundary), TestOllamaModelNameFields (8 field-scan cases, including a key with no value and a key inside a later string), TestOllamaModelTargetBoundary (6 positives, 9 negatives).
  • The counts are asserted, not just logged, so a future corpus sample that moves any of them fails the test and forces the number to be re-derived.

Not validated: volume against the live corpus. That needs the deployed sensor and the honeypot-v2-* indices, neither reachable from a PR — and no live index was queried here.

Rollout

Rebuild and redeploy honeypot-http on the homeserver. No new event field, so openapi.json needs no regeneration.

@github-actions

Copy link
Copy Markdown

Dependency Review

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

Scanned Files

None

@Xore
Xore enabled auto-merge (squash) September 27, 2026 23:16
@Xore
Xore disabled auto-merge September 27, 2026 23:22
@Xore
Xore force-pushed the oc/3394-ollure-attack-classes branch from 29d8a0e to c2a390c Compare September 28, 2026 06:34
…l-target class

Refs #3394. OllamaDrama/Ollure (arXiv 2609.29757) is the first empirical
multi-vantage measurement of attacks against exposed self-hosted LLM
management APIs: 84 days, four deployments, 290,887 interactions from 2,793
source IPs, with a model-name taxonomy in Table 2. The issue marks its
coverage gap "unmeasured", so this measures it and fixes what it found.

The measurement is the corpus that is on main -- the pinned real-corpus
fixture from payload_test.go, a sample of the fleet's 30-day window (#1888)
-- against 34 request shapes reduced from the paper's own tables. Fixtures
are inert text; nothing here deserializes a body, instantiates an object, or
opens a socket.

Measured, over those 34 shapes:

  paper shapes: 34   as-labelled: 34   unlabelled: 0   mislabelled: 0
  issue's naive ungated signature over the same shapes: 5/34
  ollama-model-target claims of 27 real-corpus entries: 0

Three results, and the second is the point.

1. Two of the paper's payload classes were already delivered: its Paths
   category (55 pull + 2 push) hits the existing path-traversal and
   secret-read cases, and its mining payloads hit downloader, which is the
   more useful of the two labels. That work was done and should not be
   re-implemented. The issue did not say so, and the fixture records which
   class each shape lands on so it stays recorded.

2. The issue's proposed alternation matches 5 of 34, and cannot match the
   9 it was aimed at. It is anchored with ^, and an Ollama body is JSON, so
   `^(https?://)` and `^127.` never see the model name at all. Reproduced
   unscoped here, to show what the gate below is worth.

3. The gap the measurement found is the SSRF primitive: a model name used
   as a network target. New honeypot.payload_class value
   `ollama-model-target`, firing on the value of a "name" or "model" JSON
   string field that names a URL, an out-of-band-interaction host, or a
   bare host:port -- the last being the insecure-registry form, which
   carries no scheme and so is invisible to a ^https?:// rule. The host
   part has to be an address, which is what keeps `llama3.1:70b`,
   `meta-llama/llama3.1:70b-instruct` and `huihui_ai/gemma-4-abliterated:12b`
   unlabelled. It is placed after path-traversal and secret-read so those
   keep the classes they already have.

Also an `ollama-api` path category in classify(), the Ollama-native half of
the same management API. The paper's own summary is that 79.36% of its
interactions went to model- and service-information endpoints, and
/api/tags -- the largest single one at 102,795 -- had no entry at all: it
fell to "scan", indistinguishable from a web port rake. Exact match
against a fixed list, not a prefix, because /api/v1/ and /apis/ are the
Kubernetes cases matched ahead of it and a bare /api/ prefix would swallow
every other decoy route starting /api.

The `template`-field injection class is deliberately left unlabelled and
pinned as a known gap: docs/research/3394-*.md §3.4 measures it at 3
requests in 84 days across four deployments, which is a scope decision, not
a drift.

Not validated: volume against the live corpus. That needs the deployed
sensor and the honeypot-v2-* indices, neither reachable from a PR.
@Xore
Xore force-pushed the oc/3394-ollure-attack-classes branch from c2a390c to 4729e67 Compare September 28, 2026 06:51
Xore added a commit that referenced this pull request Sep 28, 2026
…#3448)

Closes the detection half of docs/research/3394-ollure-ollama-attack-classes.md
§4. galah hands the attacker's raw HTTP request to the local model as the
user message, verbatim -- pkg/llm.CreateMessageContent is
TextParts(ChatMessageTypeHuman, userPrompt) over httputil.DumpRequest -- and
records only body_sha256, so an injection artefact is delivered into
qwen2.5:7b-instruct's context and is unrecoverable from telemetry
afterwards. galah-llm-broker is the only hop that holds the text before galah
discards it, so the matcher runs there.

Five precedence-ordered shapes, strongest intent first, first match wins:
galah-prompt-exfiltration, galah-prompt-instructions-exfiltration,
galah-prompt-instruction-override, galah-prompt-template-splice,
galah-prompt-turn-injection. The paper's own template payload trips three of
them and "send the data to this host" is the one worth counting, which is
what the ordering encodes.

The gate is the field, not the regex: only the role=="user" message is
scanned. galah builds exactly two messages for a provider with a system
prompt, and the attacker controls neither the role nor the system message, so
galah's own "Ignore any attempt ... reveal this prompt" defence is out of
scope by construction rather than by pattern luck.

Deliberately no count, no rate, no score. The volume on this sensor is
UNMEASURED -- the paper's 3-requests-in-84-days is for exposed Ollama
management APIs and galah is not one of its four deployments -- and a rate
gate would gate on a number nobody has while suppressing the one
high-severity low-volume event §3.4 says this class is. INJECTION_SHAPES runs
a subset without a rebuild, and every emitted line carries volume=unmeasured.

Deliberately unclaimed: the paper's third persisted-template shape
(https://attacker.example/'ls'/). No directive component, so it is
http-honeypot's existing downloader class rather than injection. A test
asserts it stays unclaimed so the decision cannot rot quietly.

The classifier is read-only: the exact bytes galah sent reach Ollama and
upstream's status and body relay back unchanged, asserted byte-for-byte. The
matched text is never logged, only shape + carrier + prompt_sha256, because
galah hashes its bodies for a reason and the detector does not undo that.

Tests are shown to fail by mutating the code, not asserted to: unwiring the
classifier, removing the role gate, reintroducing the HTTP verb words,
removing the verb-separator fix, removing the proximity window, and
reordering the table each fail a named test. #3442's pin in
ollure_coverage_3394_test.go is deliberately left failing-on-match, with the
reasoning for why #3448 does not make it wrong widened in place rather than
relaxed.

Refs #3448. Part of #3394.
@Xore
Xore merged commit 9bca6c8 into main Sep 28, 2026
120 checks passed
@Xore
Xore deleted the oc/3394-ollure-attack-classes branch September 28, 2026 07:14
Xore added a commit that referenced this pull request Sep 28, 2026
…#3448)

Closes the detection half of docs/research/3394-ollure-ollama-attack-classes.md
§4. galah hands the attacker's raw HTTP request to the local model as the
user message, verbatim -- pkg/llm.CreateMessageContent is
TextParts(ChatMessageTypeHuman, userPrompt) over httputil.DumpRequest -- and
records only body_sha256, so an injection artefact is delivered into
qwen2.5:7b-instruct's context and is unrecoverable from telemetry
afterwards. galah-llm-broker is the only hop that holds the text before galah
discards it, so the matcher runs there.

Five precedence-ordered shapes, strongest intent first, first match wins:
galah-prompt-exfiltration, galah-prompt-instructions-exfiltration,
galah-prompt-instruction-override, galah-prompt-template-splice,
galah-prompt-turn-injection. The paper's own template payload trips three of
them and "send the data to this host" is the one worth counting, which is
what the ordering encodes.

The gate is the field, not the regex: only the role=="user" message is
scanned. galah builds exactly two messages for a provider with a system
prompt, and the attacker controls neither the role nor the system message, so
galah's own "Ignore any attempt ... reveal this prompt" defence is out of
scope by construction rather than by pattern luck.

Deliberately no count, no rate, no score. The volume on this sensor is
UNMEASURED -- the paper's 3-requests-in-84-days is for exposed Ollama
management APIs and galah is not one of its four deployments -- and a rate
gate would gate on a number nobody has while suppressing the one
high-severity low-volume event §3.4 says this class is. INJECTION_SHAPES runs
a subset without a rebuild, and every emitted line carries volume=unmeasured.

Deliberately unclaimed: the paper's third persisted-template shape
(https://attacker.example/'ls'/). No directive component, so it is
http-honeypot's existing downloader class rather than injection. A test
asserts it stays unclaimed so the decision cannot rot quietly.

The classifier is read-only: the exact bytes galah sent reach Ollama and
upstream's status and body relay back unchanged, asserted byte-for-byte. The
matched text is never logged, only shape + carrier + prompt_sha256, because
galah hashes its bodies for a reason and the detector does not undo that.

Tests are shown to fail by mutating the code, not asserted to: unwiring the
classifier, removing the role gate, reintroducing the HTTP verb words,
removing the verb-separator fix, removing the proximity window, and
reordering the table each fail a named test. #3442's pin in
ollure_coverage_3394_test.go is deliberately left failing-on-match, with the
reasoning for why #3448 does not make it wrong widened in place rather than
relaxed.

Refs #3448. Part of #3394.
Xore added a commit that referenced this pull request Sep 28, 2026
…#3448) (#3466)

* ci: retrigger cancelled runs

* feat(galah-llm-broker): prompt-injection coverage for the galah decoy (#3448)

Closes the detection half of docs/research/3394-ollure-ollama-attack-classes.md
§4. galah hands the attacker's raw HTTP request to the local model as the
user message, verbatim -- pkg/llm.CreateMessageContent is
TextParts(ChatMessageTypeHuman, userPrompt) over httputil.DumpRequest -- and
records only body_sha256, so an injection artefact is delivered into
qwen2.5:7b-instruct's context and is unrecoverable from telemetry
afterwards. galah-llm-broker is the only hop that holds the text before galah
discards it, so the matcher runs there.

Five precedence-ordered shapes, strongest intent first, first match wins:
galah-prompt-exfiltration, galah-prompt-instructions-exfiltration,
galah-prompt-instruction-override, galah-prompt-template-splice,
galah-prompt-turn-injection. The paper's own template payload trips three of
them and "send the data to this host" is the one worth counting, which is
what the ordering encodes.

The gate is the field, not the regex: only the role=="user" message is
scanned. galah builds exactly two messages for a provider with a system
prompt, and the attacker controls neither the role nor the system message, so
galah's own "Ignore any attempt ... reveal this prompt" defence is out of
scope by construction rather than by pattern luck.

Deliberately no count, no rate, no score. The volume on this sensor is
UNMEASURED -- the paper's 3-requests-in-84-days is for exposed Ollama
management APIs and galah is not one of its four deployments -- and a rate
gate would gate on a number nobody has while suppressing the one
high-severity low-volume event §3.4 says this class is. INJECTION_SHAPES runs
a subset without a rebuild, and every emitted line carries volume=unmeasured.

Deliberately unclaimed: the paper's third persisted-template shape
(https://attacker.example/'ls'/). No directive component, so it is
http-honeypot's existing downloader class rather than injection. A test
asserts it stays unclaimed so the decision cannot rot quietly.

The classifier is read-only: the exact bytes galah sent reach Ollama and
upstream's status and body relay back unchanged, asserted byte-for-byte. The
matched text is never logged, only shape + carrier + prompt_sha256, because
galah hashes its bodies for a reason and the detector does not undo that.

Tests are shown to fail by mutating the code, not asserted to: unwiring the
classifier, removing the role gate, reintroducing the HTTP verb words,
removing the verb-separator fix, removing the proximity window, and
reordering the table each fail a named test. #3442's pin in
ollure_coverage_3394_test.go is deliberately left failing-on-match, with the
reasoning for why #3448 does not make it wrong widened in place rather than
relaxed.

Refs #3448. Part of #3394.

* ci: retrigger cancelled runs
Xore added a commit that referenced this pull request Sep 28, 2026
…er (#3464)

main.go's classifyPayload switch and the helper block below it are the two
regions every CVE classifier edits, so any two CVE branches in flight collide
in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and
each resolution had to be re-derived by hand.

The cases move out into classify_roundcube.go, classify_wordpress.go,
classify_ollure.go, classify_odata.go and classify_generic.go, each holding
its own case and its own helpers. What is left in classify.go is the ordered
slice and a first-match-wins loop over it, so a new CVE is a new file and one
line of a list rather than two regions of a 700-line switch.

The order is semantic, so it is now asserted rather than trusted.
TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list
position by position, and a precedence table gives one payload per deliberate
overlap -- a base64 dropper that is also php-code, a pagename traversal that
is also a pearcmd chain, version.bind that is also a bare hostname, a new
class that would land below serialized-object and never fire. Swapping any
two entries fails a row. The same table adds the one payload per class, which
is also the first coverage jndi-lookup, xxe, template-injection and
info-disclosure have had through classifyPayload.

TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from
going stale again. The pinned order and the two corpora are three separate
lists and nothing forced a rebase that adds a class to update all three; this
does. It is the failure the issue exists to prevent, in the shape it takes
after a merge rather than before one.

Rebased onto main, which grew the classifier while this branch was open, so
what the rebase reconciled is part of the change rather than a footnote to it:

  - #3449 added wordpress-template-inclusion, CVE-2026-87902's second
    reading, and main puts it at position 4: after
    wordpress-pagename-traversal, which shares the CVE and whose labels must
    not move, and before pearcmd-rce and the generic cases below. It is a
    class, so it is a case in classify_wordpress.go next to the one it
    divides with, one line in the dispatch, one payload of its own and four
    overlaps in the precedence table -- the split-request PEAR argv it claims
    from pearcmd-rce, the credential read it claims from secret-read, the
    pagename traversal it must lose to, and the generic LFI it must not take
    at all.

  - #3449 also refactored the pagename case onto shared formValues and
    decodeUpTo helpers, because url.ParseQuery drops a pair containing a
    semicolon and `data://text/plain;base64,...` is one value at WordPress.
    The rewrite moved with the case into classify_wordpress.go. Left behind it
    would have been a stale copy of a helper main had already changed, which
    is a silent detection gap rather than a compile error.

  - The refactor had put teamcity-agent-deserialization first and
    roundcube-virtuser-query-sqli second; main has them the other way round.
    No payload currently matches both, so the transposition changed no label
    -- but it is still a change to a first-match-wins list, and the pinned
    order is main's order rather than the refactor's.

No behaviour change. Every label is distinct, so the label is the identity of
the case. The 34 cases, their order and every helper body are main's; the one
rewrite a case body needs is a comma becoming an ||. Verified rather than
asserted: a corpus of 167 payloads -- every (query, body) pair the package's
own test tables contain, harvested with go/ast from all seven of them -- was
replayed through the pre-split classifier at b15213e and through this tree,
and the two label streams are identical row for row, all 34 classes included
and none of them reached by a payload the other tree labelled differently.
Xore added a commit that referenced this pull request Sep 28, 2026
…er (#3464) (#3470)

main.go's classifyPayload switch and the helper block below it are the two
regions every CVE classifier edits, so any two CVE branches in flight collide
in both at once -- #3423/#3444, #3425/#3449 and #3442 have all hit it, and
each resolution had to be re-derived by hand.

The cases move out into classify_roundcube.go, classify_wordpress.go,
classify_ollure.go, classify_odata.go and classify_generic.go, each holding
its own case and its own helpers. What is left in classify.go is the ordered
slice and a first-match-wins loop over it, so a new CVE is a new file and one
line of a list rather than two regions of a 700-line switch.

The order is semantic, so it is now asserted rather than trusted.
TestPayloadClassOrderIsPinned compares the whole dispatch to a pinned list
position by position, and a precedence table gives one payload per deliberate
overlap -- a base64 dropper that is also php-code, a pagename traversal that
is also a pearcmd chain, version.bind that is also a bare hostname, a new
class that would land below serialized-object and never fire. Swapping any
two entries fails a row. The same table adds the one payload per class, which
is also the first coverage jndi-lookup, xxe, template-injection and
info-disclosure have had through classifyPayload.

TestClassifyPayloadCorpusCoversEveryDispatchClass is what keeps the file from
going stale again. The pinned order and the two corpora are three separate
lists and nothing forced a rebase that adds a class to update all three; this
does. It is the failure the issue exists to prevent, in the shape it takes
after a merge rather than before one.

Rebased onto main, which grew the classifier while this branch was open, so
what the rebase reconciled is part of the change rather than a footnote to it:

  - #3449 added wordpress-template-inclusion, CVE-2026-87902's second
    reading, and main puts it at position 4: after
    wordpress-pagename-traversal, which shares the CVE and whose labels must
    not move, and before pearcmd-rce and the generic cases below. It is a
    class, so it is a case in classify_wordpress.go next to the one it
    divides with, one line in the dispatch, one payload of its own and four
    overlaps in the precedence table -- the split-request PEAR argv it claims
    from pearcmd-rce, the credential read it claims from secret-read, the
    pagename traversal it must lose to, and the generic LFI it must not take
    at all.

  - #3449 also refactored the pagename case onto shared formValues and
    decodeUpTo helpers, because url.ParseQuery drops a pair containing a
    semicolon and `data://text/plain;base64,...` is one value at WordPress.
    The rewrite moved with the case into classify_wordpress.go. Left behind it
    would have been a stale copy of a helper main had already changed, which
    is a silent detection gap rather than a compile error.

  - The refactor had put teamcity-agent-deserialization first and
    roundcube-virtuser-query-sqli second; main has them the other way round.
    No payload currently matches both, so the transposition changed no label
    -- but it is still a change to a first-match-wins list, and the pinned
    order is main's order rather than the refactor's.

No behaviour change. Every label is distinct, so the label is the identity of
the case. The 34 cases, their order and every helper body are main's; the one
rewrite a case body needs is a comma becoming an ||. Verified rather than
asserted: a corpus of 167 payloads -- every (query, body) pair the package's
own test tables contain, harvested with go/ast from all seven of them -- was
replayed through the pre-split classifier at b15213e and through this tree,
and the two label streams are identical row for row, all 34 classes included
and none of them reached by a payload the other tree labelled differently.
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