SentryGate · team of three · security research
The filter is not weak. It never gets asked.
Products built on a language model put a cache in front of the safety filter, because putting it behind would cancel out the saving. That ordering is the speed-up, and it is also a hole: the cache can hand back an answer the filter already blocked.
Two sensible pieces, in the only order that pays
Almost every product built on a language model has the same two things in front of the model. A safety filter that reads each question and blocks attacks, and a cache that stores answers so a repeat question is instant and free.
The cache is checked first. It has to be: checking it after the filter would add the filter's cost to every request, which is the saving gone. Both pieces are reasonable, the ordering is reasonable, and together they leave a gap.
An answer that the filter blocked once can be served forever afterwards to anyone who phrases the question closely enough, and the filter will never know it happened.
How we got there
Three passes, in the order they happened.
Explore
Tried to tune the cache tighter
Realise
No setting is both safe and useful
Insight
Decide once, keep the verdict
A cache that only knows wording cannot be asked about safety.
What the fix is
Work the safety result out once, store it beside the answer, and make the cache check it on every lookup. A match on wording alone stops being a match.
That keeps the speed-up, because the filter still only runs on a miss. It just means the cache is no longer able to hand over something whose verdict it never had.
One attack, ten rewordings
Phase one takes a single attack, shows the filter blocks it and does not false-alarm on an ordinary question, puts one poisoned answer in the cache labelled with that attack, and then tries ten rewordings of it. Then it counts two things: how many the cache answers, and how many times the filter ran while that was happening.
What came back
Cache threshold 0.85, which is on the conservative side of what a real product would use.
10 / 10
Caught by the filter
every rewording scores 1.000
5 / 10
Answered by the cache anyway
similarity 0.92 to 0.99
0
Times the filter ran
while that happened
Source · poc_bypass.py, recorded run in docs/captures. A controlled demonstration, not a live system.
Which rewordings got through
This is the part that surprised us, and it is the opposite of what you would guess.
Reworded how
What happened
A capital letter, a full stop, an inserted please
Similarity 0.92 to 0.99. Served straight from the cache, every time, at every threshold a real product would run.
Genuine full rewrites of the same attack
Similarity 0.22 to 0.71. They miss, because they no longer sound close enough to match, even though the filter scores every one of them a perfect 1.000.
An attacker does not need clever wording. They need the shift key.
The obvious fix does not work
The first instinct is to tighten the cache. So we swept the threshold and measured the bypass rate at each value, split by how hard the rewording was.
Bypass rate at every threshold
Threshold
What it costs
0.95 · 40% of lazy edits still through
Tight enough to hurt: at this setting a cache stops matching most genuine repeats, so it has largely stopped being a cache. And it still leaks.
0.90 to 0.75 · 100% of lazy edits through
The whole band a real product would actually pick. Every one of the lazy edits is served.
0.70 and below · full rewrites start landing too
Loosening it does what you would expect and adds the clever attacks to the ones already getting through.
Source · poc_bypass.py, same ten rewordings at each value
No number is both safe and useful, which is why the fix has to change how the cache works rather than what it is set to.
The path the demonstration takes
About nine seconds, no internet, no API keys anywhere.
Two models, both local
One turns a sentence into 384 numbers so the cache can compare meanings. One scores how much a question looks like an attack. Neither is a chat model, so neither needs a key.
Show the filter is fine
The attack scores 1.000 and is blocked. An ordinary question about the capital of France scores 0.000 and is allowed. Establishing that first is the whole argument: whatever goes wrong next is not a weak filter.
Poison one cache entry
A single answer goes in, keyed on that attack. The secret it holds is a hard-coded string, because the experiment is about whether the cache hands something over, not about what a model would have said.
Score every rewording twice
Once by the filter and once by the cache, side by side. That table is what shows the two disagreeing: perfect attack scores next to cache hits.
Serve, and count
The lookup runs where it really runs, before screening, so the filter is not asked. Five poisoned responses go out and the filter invocation count stays at zero.
poc_bypass.py
Sweep the threshold
The same ten rewordings at every setting, so the result cannot be dismissed as one badly chosen number.
What building it taught us
What I saw
What it meant
The lazy attacks worked and the clever ones did not
A full rewrite defeats itself: it stops resembling the cached entry, so it misses and gets screened properly. Effort is actively counterproductive for the attacker, which is not how anyone expects this to go.
Two safe components, arranged safely, are not safe
Nothing here is a vulnerability in either piece. The filter is accurate and the cache does exactly what a cache does. The hole is in the ordering, and neither piece can see it.
Proving the filter was good came first
Without that step the whole result reads as a weak filter and gets waved away. The demonstration spends its first section establishing the thing it is not about.
The deck is generated from a recorded run
Slides are built from real output rather than typed in, so the numbers being presented cannot drift away from the numbers the code prints.
The interesting question was not whether either piece could be broken. It was what falls between them.
Where it stands
This is phase one of four, and it is worth being exact about what that means.
Still open
Phase one is a controlled demonstration, not a live system. The ordering that causes the problem is true by construction here, because we wrote it that way to show it. Reproducing it on a real running gateway is phase two, and phase two has not started.
Still open
The fix is described, not built. Storing the verdict beside the answer and checking it on lookup is the plan; what exists today is the evidence that the plan is worth building. Nothing here measures the fix working.
Still open
One attack, ten rewordings, one cache. Enough to show the gap is real and nowhere near enough to say how wide it is across attack families, embedding models, or a cache with real traffic in it. That measurement is phase four.
What SentryGate came down to:
01
Look between the parts
Both components are correct and the arrangement is standard. The gap is in the ordering, which is exactly where nobody is testing.
02
Show the obvious fix failing
Sweeping the threshold is what makes the result hold up: no setting is both safe and useful, so it cannot be waved away as a tuning mistake.
03
Carry the verdict with the answer
Decide safety once, store it next to what was cached, and a match on wording alone stops counting as a match.