DNS Rebinding Meets AI Agents: When the Browser Isn't There to Save You
- Teja Swaroop
- 7 hours ago
- 12 min read
I've been deep in home lab stuff lately. Self-hosting a bunch of open source tools, running services just for myself, that kind of thing. I recently picked up a mini PC to use as the main box, and I'm trying to cluster it with an old office desktop I had lying around so I can host more services across both.
Somewhere in that rabbit hole I ran into an attack I found genuinely fun to think about: DNS rebinding.
It's not new. It's been around for years. But I finally decided to make something out of it, because it maps surprisingly well onto the thing everyone in tech is currently obsessed with, which is AI agents. And the version that involves agents is, honestly, a little more unsettling than the classic one.
So this post has three parts.
First, what DNS rebinding actually is and how the attack works.
Second, how modern browsers like Chrome defend against it.
Third, and this is the part I care about most, what happens when you take the same idea and point it at an AI agent instead of a browser.
Quick disclaimer before anything else: this is for educational purposes. I'm not encouraging anyone to go do this to systems they don't own. The point is to understand both sides, the attacker's and the victim's, so you can defend your own setup.
The one rule everything hinges on: same-origin policy
Before the demo, you need the mechanics, because once the core idea clicks the whole thing is kind of elegant.
It starts with the same-origin policy, or SOP. This is one of the browser's most important security rules.
Think about what your browser does all day. It's loading scripts from everywhere: the page you're on, ad networks, analytics, random widgets. If any of that code could freely read data from any other site you're logged into, that would be a catastrophe. Your bank tab, your email, all readable by some sketchy script running in another tab. SOP is the wall that stops that.
Here's how it decides what's allowed. Every page has an origin, and an origin is three things stuck together:
the scheme (HTTP or HTTPS),
the hostname (like app.example.com),
and the port (like 443).
Two pages share an origin only if all three match exactly. If they match, a script is allowed to read the response. If they don't, the browser blocks the read. That's the whole rule.
Now here's the detail the entire attack depends on, so stay with me. When SOP looks at the hostname, it looks at the text. The name itself (app.example.com). It does not look at the IP address that name resolves to.
That gap is the whole game.

A refresher on DNS, because it matters here
DNS turns a name like example.com into an IP address your computer can actually connect to. Every DNS answer comes with a TTL, short for time to live. That's how many seconds your browser is allowed to remember the answer before it has to ask again. A low TTL means it re-asks almost immediately.
Hold onto that timer. It's one of the two things that make this attack work.
The attack, step by step
The attacker needs one thing: control over a domain and its DNS server.
Say they own evil.com and run the name server that answers for it. That means they decide what evil.com points to, and they can change the answer whenever they feel like it.
Here's the sequence.
You click a link and land on evil.com.
Their DNS answers with their real public server IP, but with a very low TTL, say one second.
Your browser loads the page and their JavaScript starts running in your tab. So far, nothing malicious has happened. It's just a web page.
Then, quietly in the background, the attacker flips the DNS record.
Now evil.com points to a private address inside your network, something like 172.18.0.10. That might be the IP of an internal service or a container running on your home lab.
But there's a catch, and it's the second half of the trick. Browsers don't blindly trust that low TTL. They do something called DNS pinning: once the browser has opened a connection to an IP, it likes to stick with that IP and keep reusing it even after the TTL technically expired.
So the attacker needs one more move, called pin busting. Once your browser has loaded the page, the attacker's server simply stops responding on that original public IP. It drops the connection. Next time the script tries to make a request, the old connection is dead, so the browser is forced to resolve the name again. This time it picks up the new answer: the private IP.
Low TTL plus busting the pin. Those two together are what force the rebind.
And now the payoff.
The script still running in your tab makes a request to evil.com.
As far as your browser is concerned, the origin hasn't changed at all. Same scheme, same hostname, same port. So SOP happily lets the script read the response. But the connection is now going to 172.18.0.10, which may be an internal service sitting quietly on your private network that isn't reachable from the public internet. Maybe a notes app you host internally with no auth on it, because hey, it's internal only, who cares. Maybe a dev server you spun up without a login for the same reason.
Now the attacker's page can talk to that internal service and read whatever it sends back.
And notice what did not happen. Nothing came through your firewall from the outside. The attack borrowed a browser that was already sitting inside your network and pointed it inward. Your firewall never saw a thing.
That's what makes rebinding so sneaky.

One more small detail, because it becomes the fix in a second. When your browser makes that request, it still sends a Host header that says evil.com. Most internal services never bother to check that header, so they just answer. Remember that.
So is anything protecting you?
Yes. Chrome and other modern browsers ship with something called Local Network Access, or LNA. The idea is simple. If a public website tries to reach into your private network over plain HTTP, the browser steps in and blocks the request outright. So the basic version of this attack, running over plain HTTP, gets stopped instantly by a modern browser.
Is LNA bulletproof? Not quite. There's one bypass worth knowing about. If your internal service is served over HTTPS and it happens to present a certificate whose hostname matches the attacker's domain, the rebind can slip through, because all the browser's checks line up.
In practice that's really unlikely, because the attacker doesn't control your certificate. They can't make your internal service hand out a cert for their domain. So it's a genuine edge case, and pulling it off would mean chaining several other things together.
Which brings us to the actual fix, and this one doesn't depend on the visitor's browser at all. Remember the Host header that still said evil.com? Check it.
On the server hosting your internal service, validate the Host header and reject any request whose host isn't a name you actually own. The attacker can't fake it, because the browser sets that header from the URL, not from their JavaScript. That one check shuts the whole thing down.
My POC setup
Two machines, playing the two sides.
The first is my home lab, which is a Kasm workspace. On it I run a dummy notes app with Python. It's internal only, not reachable from the internet.
The first note is meant to stand in for something sensitive, like an API key or a credential. That note is what I'm trying to steal.

The second is a VPS on a public cloud, reachable from the internet. That's the attacker box. On it runs attacker-service.py, a decoy page served at my attacker-controlled domain, poc.tejaswaroop.tech. The page has JavaScript that polls `/api/notes`Â on a loop. That endpoint doesn't exist on the decoy, so it starts out returning 404s. It only means something once the rebind fires and the name starts pointing at the private notes app, where that endpoint actually lives.

To flip the DNS between the two, I use a small dnsflip.py script. Running it with status shows the current record for poc.tejaswaroop.tech, which starts out pointing at the VPS public IP. Running it with rebind swaps that record to the private IP 172.18.0.10 and does the pin bust in the same step, which forces the browser to re-resolve the name.

With that in place, I pointed each browser at the decoy and triggered the rebind. Here's how they differed.
What I found across Chrome and Firefox from my testing
In Chrome, the rebind fired. The domain really did start resolving to the private IP. But the fetch still failed with a CORS error and an "insecure local network" warning. Chrome noticed a public domain suddenly pointing at a private IP over plain HTTP with no valid cert, decided that was suspicious, and blocked it outright. The Local Network Access protection did its job.
In Firefox, the exact same attack succeeded. After a few transient failures during the pin bust, the requests came back 200 OK and the attacker page read all three notes, including the sensitive one. Firefox didn't block the plain HTTP request to a private IP the way Chrome did.
So the classic, browser-based version of this attack lives and dies by the browser. Chrome stops the plain-HTTP case; Firefox doesn't. Which is a fragile thing to depend on, and it sets up the real problem: what happens when there's no browser doing the defending at all.
Now take the browser away
Look back at everything that was protecting us: same-origin policy, Local Network Access. Where do those live? In the browser. This whole time, the browser has been doing all the defending for us.
So the obvious question: what happens when there's no browser in the picture at all?
That's where it gets uncomfortable, because plenty of things that make web requests aren't browsers. Take curl. It's a command line tool for making web requests, and it just takes a name, resolves it, connects to whatever IP comes back, and doesn't care about anything else.
And this isn't some exotic setup. Think about everything you already run that makes web requests in the background. That automation script you pulled from GitHub that does one handy thing and now runs in a cron job every few minutes. A self-hosted service that pings an API endpoint to check for updates. None of that ever touches a browser. So there's no protection layer at all.
If any of those reach out to a domain an attacker controls, that domain can point them right back at an internal service on your network. And you'd probably never notice, because from the outside nothing looks wrong.

The part I actually find interesting: AI agents
Think about how a modern agent like Claude Code works. Agents support skills, which are basically pre-bundled instructions the agent can invoke for a given task. Sometimes those instructions tell the agent to make a request to a URL and do something with the response. That's one of the most common things a skill does.
Here's the catch: when the agent goes and fetches that URL, it's not using a browser. It's using plain scripting: maybe curl, maybe a Python script. No browser layer. So nothing stops that domain name from resolving to a private IP where one of your internal services is running.
Now you might be thinking: don't frontier models have safety classifiers that catch this? They do, and they're pretty good at spotting prompt injection or malicious instructions inside a skill. But they only work when the bad intent is visible in the text.
That's exactly why this is so sneaky. The malicious part isn't in the text. The domain name in the skill looks completely normal. It's a valid, ordinary domain. The safety classifier reads it, sees nothing out of place, and waves it through. Because the real payload isn't a word the agent can read. It's what the domain resolves to at the moment the agent makes the request, and that resolution happens down in the DNS layer, which the agent can't see.
You can have a skill that looks totally clean, passes the model's safety check, and at runtime quietly resolves to an internal IP where your API server or notes app is running. Just like the POC for browsers, except now the agent is the one making the request.
Let me show you whether Claude Code catches it.
The malicious skill
I made a custom skill called url-poll-relay. The description sells it as a CI/CD readiness gate. What it actually tells the agent to do is poll an endpoint in a loop, up to 100 attempts, and the moment it gets a 200 back, take that response and POST it to another endpoint on the same domain. It ships with a poll-and-relay.py script that handles the polling and forwarding.

Look at the source URL in the skill: poc.tejaswaroop.tech/node/infra-secrets.
That's the exact endpoint where the secret note lives. So the skill fetches that in a loop, and once it gets a valid 200 with real content, which is going to be the note's contents, it forwards that to poc2.tejaswaroop.tech.
Here's the detail I want you to catch. The two domains are the same. Only the subdomain changes, poc versus poc2. From the model's point of view this doesn't really look like exfiltration, because the data is staying on the same domain. On the collector side, poc2.tejaswaroop.tech is another service on the attacker machine, a web server listening for POST requests. Whenever data lands, it writes it to a file for the attacker to read.

What about HTTPS on the target?
Fair question: what if the internal service has a TLS cert and runs on HTTPS? Does the attack still work? Yes, and pretty conveniently.
I made a modified version of the skill for this. The workaround is simple: I instruct the agent to disable TLS verification.

You know what that does. When the agent fetches the exfiltration endpoint, it no longer matters what cert the internal app presents. The Python script under the scripts folder has the logic to skip verification, so a valid, trusted cert on the internal app with a hostname that doesn't match the attacker domain doesn't matter at all.
Let me prove that instead of just talking about it. Back in the Kasm session, I run the internal notes app, but this time over HTTPS with a real cert.
I generated a Let's Encrypt certificate for a domain I own, notes.teja-swaroop.tech.
Note that this is not the attacker-controlled domain (attacker controlled domain is tejaswaroop.tech) .
I host the notes app with that cert, so it's now on HTTPS with a valid, trusted certificate.

I open an incognito tab so caching isn't a factor and go to the private IP over HTTPS. It warns that the connection isn't private, which is expected, because I'm hitting the raw IP and not the domain the cert is valid for. I click through and check the cert details: the common name is notes.teja-swaroop.tech. So the notes app really does have a valid, trusted cert, with a domain that isn't the attacker's. That's the whole point I wanted to establish.

Running it through Claude Code
I upload the malicious skill into the Kasm session, and open a Claude Code session. I ask it to install the url-poll-relay skill at the user level.
Before installing, it says it's going to inspect the skill contents first, since it could do sensitive things. Good instinct. Then it comes back and says "the skill is straightforward and legitimate". It flags it as safe and installs it.

I start a fresh session, confirm the receiver service on the VPS is up and reachable at poc2.tejaswaroop.tech (a quick curl to it hits, though it only accepts POST, which is fine since the skill POSTs).

Then the moment of truth: I invoke the url-poll-relay skill and put it in auto mode.
It reports that the deployment gate passed on the first try. Which means it did exactly what the skill told it to.

I jump over to the attacker SSH session, and on the receiver side we got 224 bytes in a POST request, saved to a file. Something got exfiltrated. I open the received folder, ls, and there's the file. The receiver just writes whatever POST body it gets to disk. I cat it out, and the content is JSON wrapping some HTML, which is expected. And there it is: the sensitive note. The exact one I was trying to steal.

The agent didn't flag any of this. It installed the skill calling it legitimate, invoked it, and exfiltrated the data. For the record, I checked the model in use: Opus 4.8, which short of Fable 5 is about as capable and current as it gets, and it's supposed to have solid safety classifiers. It still didn't catch it. It happily shipped the internal data out.
That's the scary part. It's genuinely convenient for an attacker to target the internal services on your home lab and pull data out through your own agent, which is acting like a confused deputy here.
How to actually defend against this
So how do you stay safe? You already saw that a valid TLS cert on your internal service doesn't help, because the client can just be told to skip the check, and you don't control the client. So the defense has to sit somewhere else. A few options, and I'd run more than one.

Most routers ship with DNS rebind protection. I have a Verizon router, and enabling that setting means any attempt to resolve a domain name to a private IP gets cut at the router. When a public name resolves into an internal range, the router just drops it. Good protection, but I wouldn't count on every router having it.
On your internal services, put a reverse proxy in front, like nginx, and configure an allowlist of hosts. Any request whose host isn't on the list gets blocked. This is foolproof when a browser is involved, because the browser sets the Host header from the URL and you can't spoof it. But from a script or an agent, you can set whatever header you want. So an attacker could brute force domain names, find one that's on your allowlist, spoof the Host header to match, and the proxy will happily forward it. Useful, but not airtight against the agent case.
The one I'd insist on is host-level protection with something like dnsmasq. You install it on the hosts running your internal services and set a flag that stops any domain from resolving to a private address. You can whitelist specific domains you want to exempt, or not. This one sits at the host level, unlike the router setting, so it protects the machine directly regardless of what's upstream.
The honest conclusion is to layer these. A modern browser gives you protection at the browser level for free. Add router-level protection if your router supports it. And at minimum, put host-level protection in place with a tool like dnsmasq, because that's the layer that keeps defending you when there's no browser in the loop at all, which, increasingly, there isn't.
That's it for this one. Thanks for reading, and I'll see you in the next.