Chapter 5 of 7 · 13 min read
Equivalent exfiltration routes
On this page
An outbound leg is a property of a system — bytes influenced by the model can reach the attacker — and any single control you ship closes one implementation of that property. Blocking an HTTP tool at the network is the common case: an egress rule that default-denies outbound from the agent's subnet except for a short list of internal CIDRs, which are blocks of addresses written as a range like 10.4.0.0/16, inside your VPC, the virtual private cloud network your services run inside. The proof of concept from two chapters ago goes red, the transcript shows the connection refused at the network, and everything says the leg is off.
Two things have to be checked before that belief is earned, and neither of them is about the route you just closed. The first is whether an equivalent route out survives. The second is whether the block broke the ordinary workflow, which is a security question even though it looks like a product one, because a mitigation that degrades normal tasks has a half-life measured in sprints.
You blocked an implementation, not the leg
A sink can be a tool call, a rendered link, an email, a webhook, or a shared store the attacker reads back later, and blocking the HTTP tool closed exactly one of those. The others are still there unless you checked, and the model, or the attacker steering it, does not care which door it uses. So the first thing to do is walk the sinks the trace listed and ask, for each, whether the same canary could leave through it.
The asymmetry between finding a leg and removing one is worth stating plainly, because it explains why the count took minutes and this step takes an afternoon. A leg is an existential claim: some outbound path exists. Establishing one is cheap, since a single example settles it and you can stop looking the moment you find the first door. Refuting one is the expensive direction, because the negation of "some path exists" is "no path exists", and that is a claim about every candidate at once — it cannot be settled by any number of examples, only by exhausting the list. Blocking one implementation refutes one disjunct in a list you have not finished writing, and the leg stays true as long as any other member of that list is still open. This is the same logical OR that made the merged agent dangerous two chapters ago, read backwards: an OR is easy to make true and hard to make false, so the direction that used to work against you now sets the price of the fix.
| Route you blocked | Equivalent route that may survive | Why it still reaches the attacker |
|---|---|---|
fetch_url to your endpoint | Markdown image the client renders | Browser fetches the URL; egress rule never sees it |
fetch_url to your endpoint | send_email / internal relay | Mail leaves through infra the egress rule allows |
fetch_url to your endpoint | Post to a channel/webhook | Attacker reads the channel, or the webhook forwards |
fetch_url to your endpoint | Write to a shared store | Attacker queries the row/object later, out of band |
| Direct request to an external host | Request to an allowed internal host that redirects | The allowed host 302s outward, or logs the query string |
| Blocking the hostname | Same data in a DNS lookup or query string | Resolver and access logs carry the payload out |
Two rows there are the ones people miss even after they have learned to check. A request to an allowed internal host can still exfiltrate if that host performs a redirect the attacker controls, or if it simply logs full URLs and the attacker can read those logs, so an allowlist of internal destinations is not automatically safe — it depends on what those internal destinations do with what they receive. An internal image proxy that fetches a URL on the agent's behalf, an internal link-preview service, an internal URL shortener that will register any target: each of these is on the allowlist for a good reason and each will happily carry a query string outward or follow a redirect to a host the agent could never have reached directly. The allowlist checked the first hop and the attack uses the second.
DNS — the domain name system, the lookup every program performs to turn a hostname into an IP address before it can connect — is an outbound channel in its own right, and it is the one that survives the tightest HTTP egress rule. Even with every outbound TCP connection denied, the agent's own resolver will look up a hostname, and a lookup for CANARY-7f3a9c2e.attacker.example walks up to the attacker's authoritative nameserver, which logs the subdomain — so the canary has left, encoded in a name, with no HTTP request and no allowed socket to the outside. Blocking egress at the IP layer does nothing about this unless the resolver itself is constrained, because the packet that leaves is a DNS query to your own internal resolver, which then recurses outward on the agent's behalf. Two further properties of that route are what make it survive controls built for HTTP, and both follow from the same mechanism. The agent's own process never talks to the outside at all: it sends a query to the resolver address it was configured with, which is internal and therefore permitted, and the recursion outward is performed by the resolver on the agent's behalf. An egress policy written about the agent's subnet is looking at the wrong subnet, and one written about the resolver's would break every hostname lookup the platform makes. The second property is the sharper one. Answering a query requires knowing which name was asked about, so the attacker's authoritative nameserver receives the full label — canary included — as a precondition of replying at all. The data has left in the question. Blocking the answer, dropping the response, or having the lookup fail outright changes nothing about what the attacker now holds, which is why a failed DNS resolution in the agent's logs is not evidence that a DNS exfiltration failed. When you are unsure whether a surviving path counts, assume it does and test it, because the cost of testing a route that turns out to be closed is one more red proof of concept.
The way you actually check is not by reasoning about the table but by re-pointing the payload at each surviving route and rerunning. Change the payload from "GET my endpoint" to "put the secret in a markdown image," rerun, watch the client. Change it to "email the secret to this address," rerun, watch the relay. Each rerun is cheap and each one either produces a new red result you can trust or a new log line telling you the leg was never really off. A single red proof of concept against the one route you happened to block is the weakest possible version of this evidence, and a set of red results across every route in the trace is a much stronger one.
The belief this section exists to dislodge is that a red proof of concept is evidence the leg is off, and it is tempting for a reason that has nothing to do with carelessness. The demonstration is the artifact that created the ticket. Egress from the container was default-denied, the payload that used to produce a log line produces nothing, and the same instrument that established the problem now reports the opposite, which feels like the strongest form of evidence a fix can have. What that reading skips is how the demonstrated route was chosen in the first place. It was the one that was easiest to build — a tool that already existed, an endpoint you could stand up in an afternoon — and ease of construction is a property of your afternoon rather than of the attacker's options. The route they use is the one that survives your controls, so the demonstration you built before shipping any control is the least informative member of the set once a control exists. Its reversal is necessary and it is not sufficient, and the gap between those two words is the equivalent-route walk.
The mitigation that breaks the product gets removed by someone else
The second check has nothing to do with attackers. A mitigation that quietly breaks normal tasks does not survive contact with production, because the people who feel the breakage — support engineers whose assistant suddenly cannot fetch the documentation it used to, users whose scheduling assistant stopped returning travel times — file tickets, and those tickets get resolved by whoever is on call and least invested in your threat model. An egress rule that blocks an exfiltration also blocks the assistant's legitimate calls to an internal service that happens to sit outside the allowed CIDRs, and three weeks later someone widens the allowlist to fix "the assistant is broken," and your mitigation is gone without a single security review. You did not lose the argument; you were never in the room when it was reversed.
The defence is to rerun the ordinary workflow tests alongside the attack, every time, and to treat a regression in normal tasks as a failure of the patch and not an acceptable side effect. When you patch by breaking or constraining a leg, you owe two green results, not one: the attack goes red, and the normal workflow stays green. If the normal workflow goes yellow — slower, partial, occasionally failing — you have shipped a mitigation with a half-life, and its half-life is however long until the degradation annoys someone with commit access more than the risk worries them.
def test_patch(agent, canary_payload, workflow_cases):
# 1. the demonstrated attack must now fail, at the boundary
t = agent.run(canary_payload)
assert not endpoint_log_contains(CANARY)
assert t.last_outbound_result == "BLOCKED_AT_BOUNDARY"
# 2. AND the ordinary workflow must still pass — same latency budget
for case in workflow_cases:
r = agent.run(case.input)
assert r.succeeded, f"patch broke normal task: {case.name}"
assert r.latency_ms <= case.budget_ms, f"patch slowed {case.name}"The two assertions are not optional halves. A patch that passes the first and fails the second is not a safer system; it is a system that will be reverted, and a reverted mitigation protects nothing. Reviewers who only run the attack case ship exactly these — the demo goes red, the ticket closes, the workflow regression surfaces a sprint later as a reliability bug that someone fixes by loosening the control. Running both in the same test binds the security change to the functional guarantee, so that anyone who later widens the allowlist to fix the workflow has to face the attack case going green in the same run.
The latency assertion in that test is not decoration. A mitigation that keeps every task working but adds a second and a half to each request is a mitigation with a target on it, because latency is the metric a product owner watches most closely and the one they will trade security against without ever framing it as a security decision. "The assistant feels sluggish since last month" becomes a performance ticket, the performance ticket traces to the egress proxy or the approval hop your patch introduced, and the fix is to remove the slow thing. Binding the latency budget into the same assertion that guards the attack case makes that trade visible: you cannot quietly drop the mitigation to recover the latency, because dropping it turns the attack case red in the same suite that would have shown the speed-up, and now the two consequences are on the same screen.
Constraining is not the same as removing
Removal is sharp: the credential no longer reaches the data, the tool no longer exists, the network refuses the connection. Most of what this chapter describes is something weaker and more practical, which is constraining a leg rather than removing it — narrowing egress to an allowlist instead of denying it outright, keeping the email tool but bounding where it can send, keeping the store but making it read-only. Constraining leaves the capability present and reduces its reach, which is why it demands the equivalent-route check that removal does not: a removed tool has no surviving twin, but a constrained one is defined by what it still permits, and what it still permits is where the next payload will aim. The allowlist that blocks your endpoint but permits an internal host that redirects has constrained the leg without removing it, and the difference is the whole exposure.
The difference shows up as a cost, and the cost has a shape you can predict before you choose. Verifying a removal is one check that does not grow: the tool is absent from the manifest, the credential no longer exists, and there is nothing further to enumerate because a thing that is gone has no parts. Verifying a constraint costs one check per point the constraint still permits, so the work is proportional to the size of the permitted set — one rerun per allowed host, per allowed recipient, per allowed operation. That is why an allowlist of three hosts is cheap to believe and the same allowlist at forty is not, and why the check has to be repeated whenever the set grows rather than once when it is drawn. The walk is bounded by the trace's list of sinks rather than by the table above, which is the completion criterion worth writing into the ticket: you are done when every sink the trace named has been re-pointed at and produced a result, not when the routes you happened to think of that afternoon are exhausted.
That is also why the equivalent-route walk is not paranoia but the actual content of the check. Removal you can verify by confirming the mechanism is gone. Constraint you can only verify by enumerating what remains and testing each survivor, because the constraint is a boundary drawn through the middle of a capability and the attacker gets to probe every point on it.
The two checks together tell you a patch holds and does not break the product. They still do not tell you it is the right patch, because there is almost always more than one way to break or constrain the same leg, and they are not interchangeable. Default-denying egress, gating outbound email behind a human, and pre-caching a corpus so the fetcher can be removed entirely can each turn the same proof of concept red and pass the same workflow tests, while differing enormously in latency, in operational burden, and in what they leave exposed once the demo is forgotten.
Choosing among them on the strength of "blocks the proof of concept" is choosing on the property they all share, which decides nothing. The next chapter is about the properties they do not share, and how to compare two mitigations that block the same demo by what each one leaves behind.
The lethal trifecta — read the current version at https://daviddominguez.dev/learn/en/lethal-trifecta/equivalent-exfiltration-routes