feat(http-honeypot): measure #3394's Ollure coverage and add the model-target class - #3442
Merged
Merged
Conversation
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Scanned FilesNone |
Xore
enabled auto-merge (squash)
September 27, 2026 23:16
Xore
disabled auto-merge
September 27, 2026 23:22
Xore
force-pushed
the
oc/3394-ollure-attack-classes
branch
from
September 28, 2026 06:34
29d8a0e to
c2a390c
Compare
…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
force-pushed
the
oc/3394-ollure-attack-classes
branch
from
September 28, 2026 06:51
c2a390c to
4729e67
Compare
This was referenced Sep 28, 2026
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) 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.
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.
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.mdis already onmainand 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, soollure_coverage_3394_test.gomeasures against the corpus that is onmain: the pinned real-corpus fixture frompayload_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.Three results, and the second is the point:
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-traversalandsecret-readcases, and its mining payloads land ondownloader— 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.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.The real gap is the SSRF primitive: a model name used as a network target. New
honeypot.payload_classvalueollama-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 barehost: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-instructandhuihui_ai/gemma-4-abliterated:12bstay unlabelled. It is placed afterpath-traversalandsecret-readso those keep the classes they already have.Also an
ollama-apipath category inclassify(). 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 toscan, indistinguishable from a web port rake. It is its own category rather than a branch ofllm-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'swantis""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
.envfiles were added. (Addresses are RFC 5737 documentation ranges, hostnames are RFC 2606 reserved. The wallet address in the fixture is synthetic.)ServeHTTP. No parsing, no I/O, no deserialization, no evaluation, no outbound request.)Validation
go vet ./...andgo test ./...inhttp-honeypot: pass — 167 tests, 0 fail (163 before, +4 new). No existing test edited, weakened, or skipped.gofmtclean.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/whoamiprefix boundary),TestOllamaModelNameFields(8 field-scan cases, including a key with no value and a key inside a later string),TestOllamaModelTargetBoundary(6 positives, 9 negatives).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-httpon the homeserver. No new event field, soopenapi.jsonneeds no regeneration.