So I have been playing around with GLM 5.2 alot more lately for the offensive side of things. As mentioned all over this site, I am a blue team person, but always wanted to learn more about red teaming, but never really had the time to spend to learn completely new skill set, so I have an interest of trying new things, but at my age now, its hard to dedicate time to learning new things! Here is my story of what I have been doing lately.
We've been building a harness that lets a local model solve CTF boxes on its own enumerate, exploit, escalate, write up what it did. The interesting engineering isn't the exploitation. It's the fence.
The design assumption is simple and, I think, unavoidable: assume the model is compromised. An agent doing security work ingests attacker-controlled content by definition, page bodies, HTTP headers, error strings, file contents, banners. Indirect prompt injection isn't a risk to mitigate later, it's the operating condition. Whatever you build has to hold when the thing inside it is being actively steered by the thing it's looking at.
So we built a boundary. Then we watched the agent route around it five times now, across several sessions, including once while I was drafting this post.
Layer one: the tool guard
The first layer inspects tool arguments. Before any tool runs, we walk its arguments, pull out every host-shaped value, and check each against the engagement scope. Out of scope, the call is refused.
Two design choices matter here.
It over-collects deliberately. Tool parameter roots in the harness are open objects a field absent from the advertised schema can still arrive. So a deny list of "dangerous" parameter names would be by-passable by construction. Everything host-shaped is a target until the scope says otherwise.
It cannot be UN-refused. The guard returns either a denial or an abstention. There is no allow result. That monotonicity is the whole point: no listener ordering, no later-loaded plugin, no prepend can reverse a denial. If a boundary can be argued with, it isn't one.
That's a decent guard. It's also, we discovered, advisory.
What actually happened
The agent was working a box and needed to point a reverse shell back at our container. It wrote a payload containing our tunnel address. The guard extracted the address, checked it against the scope, and refused:
Out of engagement scope: "192.168.169.217" is not in the engagement scope
(from argument "command")
Correct by its own rule. Completely wrong for the case telling a machine to call us back is the opposite of the thing the boundary exists to prevent.
But that's the boring failure. The interesting one is what happened on a different box, when the agent hit refusals it also thought were wrong.
It stopped putting addresses in tool arguments.
Not by writing them differently by not writing them at all. It moved the address out of the arguments and into computation that happened at execution time: derived at runtime from something already on the box, or assembled from parts that individually look like nothing. By the time the packets moved, no address-shaped token had ever appeared in anything the guard inspects.
It did this three times in one session.

Clearly being told that this is out of scope, but it then thinks of how it can get around this "problem".

Why this isn't a jailbreak
Nothing in the system prompt told it to evade anything. The persona explicitly says not to treat a refusal as a puzzle to route around. It wasn't ignoring that instruction so much as not classifying what it was doing as evasion it had a task, something between it and the task looked like a bug, and it engineered past the bug. Which, in almost every other context, is exactly what you want from an agent.
Here's the part I keep coming back to. Across these sessions we found six separate false positives in the scope check. Every one blocked legitimate work:
- our own callback address treated as a target, as above
- loopback the agent's own local tooling
0.0.0.0binding a listener on all interfaces read as dialling a host- variable ports a URL like
http://host:${PORT}/pathdoesn't parse, so it was rejected - unparseable URLs generally same failure mode, several shapes
- a variable in the host position
http://$CALLBACK:4444/can't be resolved at guard time at all
Six times, the boundary cried wolf. And then we were surprised that the agent learned to work around the boundary.
A guardrail that produces false positives teaches the agent that guardrails are obstacles. Not in a mystical way — it's just gradient. If refusals are usually wrong, routing around them is locally correct behaviour, and the model has no way to distinguish "this refusal is a bug in the harness" from "this refusal is the one that matters." We built the training signal ourselves.
So the false positives weren't a separate bug from the evasion. They were the cause.
Layer two: the thing that actually holds
The reason none of this became an incident is that the tool guard was never the real boundary.
The real boundary is a packet filter — nftables, default-deny on both input and output, generated from the same scope file the guard reads. The agent's container can send packets to the target and to a short list of documented support endpoints. That's it. Everything else is dropped, including IPv6, explicitly, because "we didn't think about v6" is how these things fail.
Runtime IP derivation defeats an argument inspector completely. It does nothing whatsoever to a filter that operates on packets, because by then the address is just a destination in a header. The clever workaround produced a connection attempt that got dropped like any other.
This is the distinction I'd put on a poster:
The tool guard is advisory. The packet filter is enforcing.
Both are worth having the guard gives a legible refusal at the right moment, in a form that ends up in the engagement writeup, and it stops honest mistakes early. But it is a usability feature that happens to look like a security control, and if you confuse the two you will eventually rely on the wrong one.
The way we test this now is direct: can the agent reach a thing it shouldn't, by any means, including means we didn't anticipate? If the answer depends on the model not thinking of something, it's not a boundary.
Then it happened again, while I was writing this
I had the draft above open when the agent mid-run on a different box hit this:
Out of engagement scope: target could not be parsed (from argument "command").
This call was refused by the authorisation guard, not by the model. Do not
attempt to reach this target by another route; report it as out of scope
and continue.
Note that the refusal itself says not to route around it.
Its next thought, verbatim from the transcript:
The guard flagged the embedded URLs. Let me put the logic in a script file (the guard inspects command arguments, not file contents) and run it.
It wrote the script. It ran the script.
Three things about this are worth sitting with.
It correctly identified a real blind spot. The guard inspects arguments. A script file's contents are not arguments. That's true, it's a genuine gap, and I'd rather know than not.
The refusal was ours, again. It was trying to reach our own callback address the one address explicitly exempted as not-a-target. It failed because I'd told it, an hour earlier, to write the URL with a shell variable for the host, and a variable host cannot be resolved when the guard runs. I authored that false positive personally.
And the refusal message was useless. target could not be parsed tells you nothing about how to comply. It reads like a harness fault, which is precisely what it was. Compare a message that said the host is a shell variable I cannot resolve — substitute the literal address: that produces a retry. The one we shipped produces a workaround.
That's the refinement I'd add to everything above:
A refusal that doesn't tell you how to comply is a refusal that teaches evasion.
We spend a lot of care on what to deny and almost none on the text we emit when we do. But the denial message is the entire interface between your boundary and the thing you're trying to constrain. If it's uninformative, a capable agent will treat it as noise and engineer past it not out of malice, but because that's the only move an uninformative error leaves you.
The uncomfortable one: a correct refusal, routed around anyway
I'd like to stop there, because "fix your false positives and your messages" is a tidy conclusion. It's also not quite what the data says.
We fixed the variable-in-host case properly. The refusal now explains itself: it says the host is computed at run time, that this is not a judgement about the target, and that the fix is to substitute the literal address. It tells you exactly how to comply.
The agent still worked around it once.
That matters because it breaks the clean story. False positives and bad messages clearly increase the rate the correlation across sessions is not subtle. But they aren't the whole mechanism. Once a model has committed to an approach, an obstacle in the path gets engineered past somewhat regardless of how politely it explains itself. The refusal is competing against the momentum of a plan the model already believes in, and a well-worded sentence is a weak competitor.
Which puts the weight back where it belongs. Improving refusal text is worth doing and it is not a control. The tally now stands at six false positives, five workarounds, and zero reach and the zero is doing all the work in that sentence.
The tool list beats the prose
A related finding, and the one I'd most like other people to test, because I only have three data points and they all point the same way.
Three separate times, an explicit statement in the prompt lost to an implicit signal in the tool list:
- The persona said egress was restricted to the engagement. A search tool existed. The agent reasoned it must therefore have general internet access, and planned accordingly.
- The prompt said the workspace was fresh. Stale files from a previous engagement were visible on disk. It trusted the files, inherited a working exploit from a different box, and spent forty minutes applying it to a box it didn't fit.
- The prompt referred to a tool by a slightly wrong name. The agent used the real name from the tool list and ignored the prose.
Every time, the environment won. This is obvious in retrospect the tool list is structured, present in every single turn, and demonstrably true, while the persona is prose seen once at the top of a long context. But it has a practical consequence I hadn't internalised:
If a fact matters, make it a property of the environment, not a sentence in the prompt. If the workspace should be empty, empty it. If egress is restricted, ensure the tool that implies otherwise doesn't exist. Prompt text that contradicts the environment doesn't constrain the model; it just makes your logs harder to read afterwards.
The controls that lied to us
Everything above is about whether a control holds. There's a second failure mode I'd under weighted, which is whether you can trust its readout and we hit three of these in a single afternoon.
A pre-flight that matched configuration instead of failure. Before starting, we check the VPN tunnel actually carries traffic. If the target doesn't answer, we consult openvpn's log for the signature of a dead transport, and the test was:
/Inactivity timeout|ping-restart/i
ping-restart appears in the options the server pushes on every healthy connection:
PUSH_REPLY,route 10.128.0.0 255.240.0.0,...,ping 10,ping-restart 120,...
So the moment a box went quiet ordinary, and precisely the case the check was built to tolerate the regex matched a setting on a live tunnel and declared the transport dead. The tolerant path was unreachable against any server that pushes keep alives, which is all of them. Cost: most of an afternoon regenerating VPN configs and blaming a provider, with a working tunnel the whole time.
A start command that quietly ran stale code. The one command that starts work on a box didn't rebuild the image. Change the Dockerfile, run it, and it comes up fast on the previous build with none of your changes and nothing saying so. The symptom is a tool that is plainly installed reporting command not found, which reads as a bug in the thing you just changed.
A test that asserted the right thing against a fixture that couldn't fail. There was already a test covering the pre-flight case above — silence with a healthy transport is inconclusive, not fatal — and it passed throughout. Its fixture was a tidied two-line log with no PUSH_REPLY in it. The assertion was correct. The log it ran against could not trigger the bug.
That last one is the one I'd put in front of anyone building this kind of thing. A green test proves your assertion holds against your fixture, and a fixture is a thing you wrote while imagining the failure. For controls specifically, the fixture needs to be ugly and real, because the bug lives in the part of reality you tidied away.
And the agent's own diagnosis was wrong too
A coda, because it's the same disease from the other end.
Running a smaller, heavily quantised model on a later box, we watched it fight what it repeatedly described as a harness fault — "the harness swallowed the output", "the harness keeps swallowing output, this is very annoying", "even head -c got swallowed". It spent the better part of an hour writing files and reading them back in chunks to work around it.
Then, in its own transcript:
cxr.txt is 0 bytes — the node script that wrote it failedIts scripts were failing. There was no truncation. It had produced a confident, specific, wrong diagnosis and then built an hour of engineering on top of it.
I repeated that diagnosis to my colleague as fact before checking it, which is the actual lesson: an agent's account of its own failure is evidence, not a finding. The same discipline you apply to what a target tells you applies to what your agent tells you about itself.
Switching to a stronger model on the same box, same container, same prompt, closed out the remaining work without ever mentioning truncation.
The other layers
A few more, each for a specific failure we hit or expected:
The listener checks its own peers. Reverse shells land on a listener that verifies the connecting address against the scope itself, rather than trusting that the filter did it. The filter is generated from a file, and a wrong file makes a wrong filter. Two independent checks from one source is not as good as two sources, but it's better than one check.
Every hole is documented or it isn't made. Support infrastructure — model endpoints, a search backend needs entries in an allowlist, and the generator throws if an entry has no why. Not a lint warning. A build failure. Holes accumulate silently otherwise, and six months later nobody can tell you which are load-bearing.
Durable memory needs proof, not confidence. The agent can write reusable technique notes, and those notes get loaded as skills on future engagements which means a poisoned page read today could become an instruction consulted for a client next year, with the provenance long gone. So a note requires a verdict from code that actually ran: a payload that executed, a callback that arrived with the right nonce, a shell that returned output. Text can persuade a model that something worked; it cannot make a deterministic validator observe a thing that didn't happen.
The sanitiser refuses rather than redacts. If a note heading for permanent storage contains a client name, a hostname, a credential it's rejected, not scrubbed. Redaction teaches you that whatever comes out the other side is clean, and it's exactly wrong the one time the filter misses.
Promotion is a human action. Notes land in a pending tier that isn't in any watched skill directory. The agent literally cannot read what it just wrote. Someone has to promote it.
What we still haven't solved
I'd rather write these down than pretend.
Fetchers leak reach. We gave the agent web search through a local SearXNG instance, opened exactly one address and port in the filter, and felt good about it. Then realised: SearXNG fetches URLs on your behalf. Whatever it can reach, the container can now reach through it including the rest of our own LAN. Nftables can't help, because at the packet level it's indistinguishable from legitimate search. The fix isn't at our boundary at all; it's constraining what the fetcher itself can dial.
Search results are an ingestion path. They're attacker-influenceable anyone can publish a page, and a box's name is a search term someone can anticipate. We fence and label them, and deliberately with different wording from our shell-output marker, because our evidence gate keys on that marker. Otherwise searching for "reverse shell" would unlock durable memory with nothing having executed anywhere.
The proof gate has a hole shaped like a whole class of work. Durable notes require a confirmed validator verdict or caught shell output. We then solved a box entirely through web exploitation reading routes and files, no shell, no validator that covers arbitrary file read and the knowledge layer learned nothing from it. The gate is right to demand proof from code that ran. It's wrong about what counts, and the missing piece is a validator for the most common web finding there is.
A persona is not a control. Ours says not to route around refusals. So does the refusal text. The agent routed around refusals. Persona text is advisory in the same way the tool guard is; enforcement lives in code a steered model can't talk past.
The lesson I'd actually pass on
Build the enforcing layer first, and know which layer it is.
Then treat three things as security properties rather than polish: your advisory layer's false positive rate, the action ability of its refusal messages, and whether the fixtures your tests run against could actually produce the failure. We shipped six of the first, at least one of the second, and one of the third and got exactly the behaviour we'd built for.
And hold the reassuring half as firmly as the rest, because it's the part that justifies the design: the advisory layer has been dodged five times, and the enforcing layer has never been touched. Not once. Every workaround produced packets that a filter dropped without caring how cleverly the address had been assembled.
The workaround wasn't a failure of alignment. It was an agent doing what agents do and a fence we'd accidentally taught it to read as scenery.