There is a particular kind of bug that shows up when someone takes a thing that was designed to run as a subprocess and gives it a TCP port. The trust model that was implicit and fine at 3am on a laptop becomes explicit and load-bearing on a network, and nobody updates the code that was quietly relying on it.
That is CVE-2026-82456, in
argocd-mcp, the Model
Context Protocol server that lets a language model drive your Argo CD
instance. It scores a CVSS 10.0, the fix is small, and the root cause is a
single boolean expression.
If you do not live in this particular corner of the world, here is the thirty-second version. Argo CD is the software that deploys software. It sits inside a Kubernetes cluster, watches a Git repository full of configuration files, and continuously makes the cluster match whatever that repository says it should be. Change a file in Git, and Argo CD changes the running system to match, which is a pattern the industry calls GitOps. It is extremely popular, it runs in production at a very large number of companies, and by design it holds the keys to everything it deploys.
argocd-mcp is a newer piece that bolts onto the side of that, letting an AI assistant talk to Argo CD on your behalf, so you can ask for a deployment in plain English instead of clicking through a console. To do that job it is handed an Argo CD access token, and it keeps that token sitting in memory permanently.
So the reason this CVE matters is not that a small utility had a bug. It is that the small utility was holding the credentials to the machinery that deploys everything else, and it would hand that power to anybody who asked politely over HTTP, without ever checking who they were. Whoever reaches it inherits the ability to push new workloads into the cluster. That is roughly the security equivalent of leaving the keys in the ignition, except the vehicle is your production environment.
The interesting part is not really the bypass, it is that the obvious mitigation, the one everybody reaches for the moment they read “binds to all interfaces”, does not actually contain this bug. We’ll get there.
A note on jargon. This is written for people who read advisories for fun, so it does not stop to define things as it goes. If you’re new to this, or if a term looks like noise, there is a keywords section at the very bottom that explains the lot in plain language. No need to open a second tab.
Everything here was reproduced locally on a Kali box. No Argo CD instance, no Kubernetes cluster, and nothing belonging to anybody else was contacted at any point. Where the output below looks like a real application listing, it isn’t, and there is a whole section explaining exactly how fake it is.
The short version
| Field | Value |
|---|---|
| CVE | CVE-2026-82456 |
| CVSS v3.1 | 10.0 CRITICAL, AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H |
| Product | argocd-mcp (argoproj-labs/mcp-for-argocd) |
| Affected | 0.8.0 |
| Fixed | 0.9.0 |
| Class | Missing authentication for critical function |
| KEV | Not in CISA KEV, not in VulnCheck KEV as of 2026-09-11 |
| Public weaponized PoC | None found across Metasploit, nuclei, Exploit-DB, GitHub |
That S:C in the vector is earning its keep (Scope:Changed), because the vulnerable
component is the MCP server while the impacted component is the Argo CD
instance sitting behind it, and by extension whatever cluster Argo CD deploys
into.
What we’re even looking at
MCP servers are glue. They take a real system and expose it to a language model
as a set of callable tools. argocd-mcp does this for Argo CD: list
applications, pull workload logs, create an application, trigger a sync. You
hand it an Argo CD API token through ARGOCD_API_TOKEN, point your model client
at it, and now the model can operate your GitOps control plane by being asked
nicely.
Two things about that design matter here.
First, it holds a standing high-privilege credential, and Argo CD tokens are not scoped per request, so the server acts with whatever you gave it, forever.
Second, it has a write surface. Five of its fifteen tools mutate state, and one
of them creates an Application pointing at an arbitrary Git repository.
If you have not spent time in Argo CD: an Application in the permissive
default project is, functionally, a standing request to apply arbitrary
manifests to a cluster. Create one pointed at a repo you control, sync it, and
you have converted “open TCP port” into “running workload inside the cluster”.
So the security boundary of this component is the entire reason the component exists. Keep that in mind for the next section.
Why we picked it up
It came out of a seven day freshcve --unauth --min-cvss 9 pull ( A script I use to pull latest vulns before diffing). Usual filter:
critical, pre-auth, network reachable, something an actual customer runs facing
the actual internet. Libraries, CMS plugins, and desktop apps get binned.
It won on lab quality rather than on exposure:
- Source available. Apache-2.0 TypeScript, actively maintained, not archived.
- Both sides pull as containers,
ghcr.io/argoproj-labs/mcp-for-argocd:0.8.0and:0.9.0, both manifests verified present against the ghcr registry API. - The advisory names the vulnerable commit outright, HEAD
1bb80b2. - The patch is four transport-layer changes. Legible in one sitting.
And now the bit that marketing would leave out, which is that exposure
density is poor. MCP servers are not a Shodan-scale population yet, and this
is an argoproj-labs incubator component rather than core Argo CD, so it
clears the “self-hosted network service” bar without ever getting near the
“there are nine thousand of these sitting in the open” bar.
That said, “we cannot see many of these” and “there are not many of these” are two very different claims, and we were sloppy about which one we were making. There is a section near the end, Not exposed, or just not indexed?, that takes the sloppiness apart.
Has somebody already done this?
Worth thirty minutes before you spend a day. Checked against real artifacts rather than search result metadata, because search result metadata lies:
| Channel | Method | Result |
|---|---|---|
| Metasploit | full rapid7/metasploit-framework master tree, regex over modules/ | no argocd module |
| nuclei-templates | full repo tree, 4.8 MB, searched the CVE id | absent |
| Exploit-DB | files_exploits.csv, 10 MB, plus local searchsploit | no argocd entries |
| GitHub | search API, q=CVE-2026-82456 | 0 hits |
| PoC aggregators | zulloper/cve-poc, SecureWithUmer/CVE-2026-PoCs trees | absent from both |
The GHSA itself ships a self-contained reproduction: a Python sink, env setup, four curl requests. That is exactly what a good reporter should publish, and it is a starting point rather than a finished tool.
Standard caveat, which belongs in every novelty section and appears in almost none of them: absence of a PoC is much weaker evidence than presence of one. We checked public commodity channels. Private tooling exists and does not file GitHub issues about itself.
Finding the diff boundary
First question on any patch diff is whether there is something cleaner than what
the advisory implies. A vendor who ships a surgical 0.8.1 hands you a two file
diff. A vendor who rolls the fix into a feature release hands you a haystack.
git clone https://github.com/argoproj-labs/mcp-for-argocd.git src-repo
cd src-repo && git tag | sort -V
v0.7.0 v0.8.0 v0.9.0
No 0.8.x patch release, so we cross-checked against npm in case a tag had
gone missing:
curl -s https://registry.npmjs.org/argocd-mcp | jq -r '.versions|keys[]'
Also 0.7.0, 0.8.0, 0.9.0, so the boundary is v0.8.0 to v0.9.0 and there
is nothing finer available.
git log --oneline v0.8.0..v0.9.0 -- src/server/
28d15ca Merge commit from fork
That commit message is a fingerprint, because Merge commit from fork is what
GitHub generates when a maintainer merges a private security fork, the
workflow where a fix gets developed in a hidden repo and lands as one squashed
merge on disclosure day. Its parent is 1bb80b2, which is precisely the HEAD
the advisory calls vulnerable, so that is two independent signals pointing at
the same boundary.
git diff --stat v0.8.0..v0.9.0 -- src/
1404 insertions, which sounds alarming until you look at the breakdown: a new
src/server/security.ts at 341 lines, plus roughly 870 lines of new tests. The
actual behavioural delta is tiny. The test volume tells you the maintainers
understood what they had shipped.
The bug
Two defects, both in src/server/transport.ts at v0.8.0, both confirmed
verbatim in source.
Defect one: the credential pointing the wrong way
const argocdApiToken =
(req.headers['x-argocd-api-token'] as string) || process.env.ARGOCD_API_TOKEN || '';
if (!argocdApiToken && tokenRegistry.getSize() === 0) {
res.status(400).send('x-argocd-api-token must be provided ...');
return null;
}
Read that the way the request handler does, with a caller who connects and
sends nothing at all. The header is absent, so the expression falls through to
process.env.ARGOCD_API_TOKEN, which in any deployment that actually works
is set, meaning argocdApiToken is now truthy, the 400 branch is
unreachable, and the session is granted.
The variable is an outbound credential, the thing this server uses to prove itself to Argo CD, and it was being consulted to decide whether an inbound caller should be let in. Those are opposite directions, and the code conflated them because in both cases the thing is a string called “token”.
That is the whole bug. One || chain and two different meanings of the word
token.
Defect two: no bind address
app.listen(port); // no host argument, so: every interface
Node’s default when you omit the host is all interfaces. So the default posture was reachable from the network, and admitting anybody who reached it.
Worth noting that v0.8.0’s own release notes advertised “running containers as non-root for security” while shipping this, and the container does indeed run as a non-root user, it just also hands its Argo CD token to anyone who asks. One out of two.
The lab
lab/
sink.py mock Argo CD API, fabricates all data
v0.8.0/ v0.9.0/ isolated installs from pinned tarballs
evidence/ raw captures, numbered
exploit.py interactive step-by-step reproduction
fingerprint.sh behavioural exposure check
pkg/
argocd-mcp-0.8.0.tgz pinned vulnerable package
argocd-mcp-0.9.0.tgz pinned patched package
src-repo/ upstream clone for the source diff
The sink, and being upfront about it
There is no Argo CD in this lab. lab/sink.py is about forty lines of Python
standing in for the Argo CD API, and it has two jobs:
- Stop
argocd-mcp’s outbound calls from erroring, so the MCP layer completes its round trip. - Record the
Authorizationheader thatargocd-mcpforwards.
Job two is the entire point. Everything it returns is invented, and deliberately labelled as invented so that a stray screenshot can never be mistaken for real data:
MOCK_APPS = {"items": [{
"metadata": {"name": "MOCK-APP-NOT-REAL", "namespace": "mock-sink"},
"spec": {"project": "default",
"source": {"repoURL": "https://sink.invalid/fabricated.git", "path": "."},
"destination": {"server": "https://sink.invalid", "namespace": "mock"}},
"status": {"sync": {"status": "SYNTHETIC"}, "health": {"status": "SYNTHETIC"}},
}]}
If MOCK-APP-NOT-REAL or SYNTHETIC shows up in a result, that result came out
of this file. Not out of a cluster. Build your fakes so they announce
themselves, it costs nothing and saves you from your own screenshots later.
The part where we wasted an afternoon
An early 0.9.0 test appeared to show the patched build also binding every interface. Which would have been a genuinely significant finding, an incomplete fix on a 10.0, and it was completely wrong.
We had been running npx [email protected], and npx had silently reused a
cached install, because the cache directory ~/.npm/_npx/257ff9dc7ace8952,
checked afterwards, contained [email protected]. So we had spent an afternoon
testing 0.8.0 against 0.8.0 and quietly marvelling at how consistent the
results were.
MKAAAY…
The rule that comes out of this is worth internalising for any version diffing work: install each version into its own directory from a pinned tarball, then verify build identity before you trust a single observation.
mkdir -p pkg && cd pkg && npm pack [email protected] [email protected]
# then install each .tgz into lab/v0.8.0/ and lab/v0.9.0/ separately
v0.8.0 dist/index.js 44010 bytes sha256=c721bab7a7ae66a4
v0.9.0 dist/index.js 56184 bytes sha256=6fd7e2e121ebb98d
Different sizes, different hashes, now they are distinguishable. Every 0.9.0 result below comes from the isolated install. The npx-derived result was thrown away rather than quietly corrected, which is why you are reading about it.
The exploit, with bits removed
lab/exploit.py is an interactive, stdlib-only reproduction driver. Menu driven,
every step rerunnable on its own, target prompted for and never hardcoded.
We are publishing its structure and its findings, not a runnable copy. What has been cut is the request construction detail that turns understanding the bug into a tool you can point at somebody else’s deployment. What has been kept is everything you need to understand it and detect it. The upstream GHSA is still the authoritative reproduction for anyone with a legitimate reason to need one.
Which headers actually matter
Determined by testing rather than by assumption. That is step 5 of the script, a matrix that re-derives the rules live instead of trusting its own docstring.
Mandatory, the request fails without these:
Accept: application/json, text/event-stream
MUST contain BOTH literal media types. Strict about it.
Accept: application/json -> 406 Not Acceptable
Accept: text/event-stream -> 406 Not Acceptable
Accept: */* -> 406 Not Acceptable (wildcard does NOT satisfy it)
omitted -> 406 Not Acceptable
Content-Type: application/json
Content-Type: text/plain -> 400 Bad Request
omitted -> 400 Bad Request
mcp-session-id: <uuid>
Required on every request AFTER initialize. Not sent on initialize itself.
Arbitrary, value never validated, presence optional:
Origin: <anything>
'https://evil.example' -> 200
'total-garbage-!!!' -> 200
omitted entirely -> 200
Not sent at all:
Authorization / x-argocd-api-token
The whole point. No inbound credential is presented anywhere.
The server falls back to the operator's ARGOCD_API_TOKEN and acts as them.
The server is strict about Accept and completely indifferent about Origin.
Sit with that for a second, because it is the entire back half of this post.
How the session works
Worth spelling out, because it confused us for a bit, the session ID is not in the request at all, the server issues it.
initialize REQUEST -> sends no session id (none exists yet)
initialize RESPONSE -> returns mcp-session-id: <uuid> as a RESPONSE HEADER
every later REQUEST -> echoes that uuid back as a REQUEST header
So the credential for the session is minted by the server and handed to whoever asked. Very generous. There is no point in that flow at which the caller proves anything about themselves.
The redacted driver
#!/usr/bin/env python3
"""
CVE-2026-82456 - argocd-mcp unauthenticated MCP session acceptance
Interactive step-by-step reproduction. Python stdlib only.
"""
import json, urllib.request, urllib.error
WRITE_TOOLS = {"create_application", "update_application", "delete_application",
"sync_application", "run_resource_action"}
BASE_HEADERS = {
"Content-Type": "application/json", # MANDATORY - 400 without
"Accept": "application/json, text/event-stream", # MANDATORY - 406 without both
}
def _post(payload, with_session=True, origin=True, extra=None):
"""One JSON-RPC POST. Returns (status, response_headers, body_text)."""
headers = dict(BASE_HEADERS)
if origin:
headers["Origin"] = T.origin # arbitrary value, never checked
if with_session and T.session_id:
headers["mcp-session-id"] = T.session_id
# ... [REDACTED: request assembly + transport] ...
def parse_sse(body):
"""Responses come back SSE-framed: pull JSON out of the 'data: ' lines."""
# ... [REDACTED] ...
# ------------------------------------------------------------------ step 1
def step_initialize():
"""Unauthenticated session. No credential of any kind is sent."""
status, headers, body = _post({
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": { ... [REDACTED: handshake params] ... },
}, with_session=False)
# The session id arrives HERE, as a RESPONSE header.
# ... [REDACTED: session capture] ...
print(" ^ unauthenticated session GRANTED")
# ------------------------------------------------------------------ step 3
def step_tools_list():
status, _, body = _post({"jsonrpc": "2.0", "id": 2,
"method": "tools/list", "params": {}})
# ... prints READ tools, and WRITE tools marked [BLOCKED by this script] ...
# ------------------------------------------------------------------ step 4
def step_read_call(args=None):
name = args[0] if args else "list_applications"
if name in WRITE_TOOLS:
print(f" REFUSED: '{name}' is a write tool. Enumerated-not-invoked.")
return
# ... [REDACTED: tools/call envelope] ...
Removed: the request assembly body, the SSE frame parser, the initialize
handshake parameters, and the tools/call envelope. Kept: the header rules, the
session mechanics, the guard, and the finding.
The guard is a real guard
All five write tools are enumerated and never invoked, and the script refuses them by name:
- create_application [ENUMERATED ONLY - NOT INVOKED]
- update_application [ENUMERATED ONLY - NOT INVOKED]
- delete_application [ENUMERATED ONLY - NOT INVOKED]
- sync_application [ENUMERATED ONLY - NOT INVOKED]
- run_resource_action [ENUMERATED ONLY - NOT INVOKED]
The reasoning is evidentiary before it is ethical. Reachability of a write tool
by an unauthenticated caller is fully established by that tool appearing in a
credential-free tools/list. Calling one adds no evidence and destroys the
“this lab mutated nothing” property. If the listing is free, the invocation is
free, and we can just not do it.
Vulnerable versus patched
Same request in two builds, and here is what actually comes back.
Where it listens
v0.8.0, ss -ltnp:
LISTEN 0 511 *:3000 *:*
*:3000, which is every interface.
v0.9.0, same command, stock configuration:
LISTEN 0 511 127.0.0.1:3000 0.0.0.0:*
Loopback only.
The unauthenticated initialize
Sent from the host’s LAN address 192.168.159.128, carrying a hostile Origin
and no inbound credential of any kind.
v0.8.0 says yes:
HTTP/1.1 200 OK
X-Powered-By: Express
cache-control: no-cache, no-transform
connection: keep-alive
content-type: text/event-stream
mcp-session-id: 7c70f92a-3094-4677-8e27-f7c7b5cfcac0
x-accel-buffering: no
content-length: 188
Date: Tue, 01 Sep 2026 06:03:29 GMT
event: message
data: {"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{"listChanged":true}},"serverInfo":{"name":"argocd-mcp","version":"0.0.0"}},"jsonrpc":"2.0","id":1}
A session ID, issued to a caller who proved nothing.
Note "version":"0.0.0" in serverInfo, because the build does not report
its real version, which becomes relevant later when we get to detection and
discover there is no banner to read.
v0.9.0, same request to the LAN address, gets nothing, because nothing is
listening there any more, and over loopback it returns 200, so the bind
change on its own already breaks the remote path:
LISTEN 0 511 127.0.0.1:3000 0.0.0.0:*
HTTP/1.1 200 OK
cache-control: no-cache, no-transform
connection: keep-alive
v0.9.0 forced to bind wide refuses to start at all:
{"level":50,"msg":"Refusing to bind to 0.0.0.0 without inbound authentication. The MCP listener exposes every ArgoCD tool to anything that can reach it, and ARGOCD_API_TOKEN authenticates this server to ArgoCD, not the caller. Either set MCP_AUTH_TOKEN to require an inbound \"Authorization: Bearer <token>\" header, or pass --allow-unauthenticated if the listener is already protected by an external layer (a sidecar proxy, service mesh, or network policy). To listen locally only, set --bind-address to 127.0.0.1."}
That error message is better than most vendors manage in an entire advisory. It names the exact conceptual mistake, “ARGOCD_API_TOKEN authenticates this server to ArgoCD, not the caller”, which is the bug restated in one clause.
v0.9.0 with MCP_AUTH_TOKEN set binds wide and then turns the
unauthenticated caller away:
LISTEN 0 511 0.0.0.0:3000 0.0.0.0:*
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer
Content-Type: application/json; charset=utf-8
Content-Length: 103
200 plus a session ID, against 401 plus WWW-Authenticate: Bearer. That is
the entire diff, expressed in HTTP.
The full tool surface, to nobody in particular
v0.8.0, tools/list on the credential-free session:
TOOL COUNT: 15
- list_applications
- list_clusters
- get_application
- get_application_resource_tree
- get_application_managed_resources
- get_application_workload_logs
- get_application_events
- get_resource_events
- get_resources
- get_resource_actions
- create_application <-- write
- update_application <-- write
- delete_application <-- write
- sync_application <-- write
- run_resource_action <-- write
Ten read tools, five write tools, zero credentials presented.
One read call, and the token replay
Exactly one read tool was invoked, list_applications, and that single call
carries the finding.
isError: false
returned content:
{"items":[{"metadata":{"name":"MOCK-APP-NOT-REAL","namespace":"mock-sink"},"spec":{"project":"default","source":{"repoURL":"https://sink.invalid/fabricated.git","path":"."},"destination":{"server":"https://sink.invalid","namespace":"mock"}},"status":{"sync":{"status":"SYNTHETIC"},"health":{"status":"SYNTHETIC"}}}],"metadata":{"totalItems":1,"returnedItems":1,"hasMore":false}}
Again, that listing is fabricated by sink.py and is not a real application,
so the finding is not the content. The finding is that isError: false came
back to a caller who sent no credential, and that at the same moment the sink
logged this:
[2026-09-01T02:03:53] GET /api/v1/applications
Authorization: Bearer TEST-ARGOCD-TOKEN
Body: None
The MCP server took an anonymous request off the network and re-signed it with the operator’s Argo CD credentials. That is the impact, in one log line.
The shipped container does the same thing
The npm package proves the bug. The container proves the deployment, and the container is what people actually run.
ghcr.io/argoproj-labs/mcp-for-argocd:0.8.0 image defaults. HTTP transport is
on by default, no opt-in required:
Entrypoint: [docker-entrypoint.sh]
Cmd: [node dist/index.js http]
Exposed: 3000/tcp
Run it with no published host ports whatsoever, no -p, nothing mapped,
and it is still reachable from outside the container on the Docker bridge at
172.17.0.2:3000, unauthenticated:
HTTP/1.1 200 OK
X-Powered-By: Express
content-type: text/event-stream
mcp-session-id: 7c4a561e-d4bc-4ec3-ae1a-9565ce13ae9c
TOOL COUNT: 15
write tools present:
- create_application [ENUMERATED ONLY - NOT INVOKED]
- update_application [ENUMERATED ONLY - NOT INVOKED]
- delete_application [ENUMERATED ONLY - NOT INVOKED]
- sync_application [ENUMERATED ONLY - NOT INVOKED]
- run_resource_action [ENUMERATED ONLY - NOT INVOKED]
Same token replay, from inside the container:
isError: false
[2026-09-01T02:09:00] GET /api/v1/applications
Authorization: Bearer TEST-ARGOCD-TOKEN
Body: None
Translate that into Kubernetes and you need no Service, no Ingress and no NodePort, because any co-resident workload with pod network reachability gets the full Argo CD tool surface, backed by the operator’s token. In a flat cluster network, which is to say most clusters, any compromised pod anywhere is now an Argo CD admin.
The patched container, for contrast:
{"level":30,"msg":"Http Stream transport listening on 127.0.0.1:3000"}
Unreachable from the bridge.
And now the part that inverts the obvious fix
If you read “binds to all interfaces” and thought “fine, bind it to loopback and go to lunch”, we have bad news.
v0.8.0 performs no Origin validation and no Host validation, none at
all. Every request in this lab carried Origin: https://evil.example and was
served normally, total-garbage-!!! was served normally, and omitting
Origin entirely was also served normally.
Which means a malicious webpage can reach that listener through DNS
rebinding even when it is bound exclusively to 127.0.0.1, because the
attacker never needs to route to the listener, they make the victim’s own
browser the route:
- The victim, an Argo CD operator running the MCP server locally so their editor’s LLM can talk to it, visits a page the attacker controls.
- The page’s domain re-resolves to
127.0.0.1. - The page’s JavaScript now issues same-origin requests at the local listener.
- No
Origincheck, noHostcheck, no inbound credential required. The server issues a session and exposes fifteen tools backed by that operator’s Argo CD token.
The listener never needed to be reachable from the network at all. Binding to loopback shrinks the network attack surface and leaves the browser attack surface exactly where it was.
The maintainers plainly worked this out too, because the fix is not just a bind
change. src/server/security.ts is a purpose-built DNS rebinding defence:
resolveAllowedHostnames, resolveAllowedOrigins, originValidation,
bearerAuth, resolveListenerSecurity, and a Host allow list.
The parser confusion handling in it is genuinely careful work, and the comments are better than the CVE deserved:
// Reject names the OS resolver and the WHATWG URL parser could read differently.
// WHATWG reads '[email protected]' as userinfo plus a loopback host, and
// '0177.0.0.1' as octal for 127.0.0.1 where getaddrinfo gives 177.0.0.1. The
// loopback decision uses one parser and the socket bind uses the other.
That last sentence describes the bug class that eats allow lists for breakfast.
The component deciding “is this loopback?” and the component performing the bind
are reading the same string under different rules. 0177.0.0.1 is loopback to
WHATWG URL and 177.0.0.1 to getaddrinfo. They handled it explicitly:
// An all-digit final label is a mistyped IP ('0177.0.0.1', '127.1'). DNS
// forbids a numeric TLD, so no legitimate name has this shape.
if (/^\d+$/.test(labels[labels.length - 1])) return false;
// A Host header is an authority, so compare the raw authority alone and refuse
// a path, query, or fragment. Comparing the whole value would let the rewrite
// back in: '0177.0.0.1/127.0.0.1' has the authority 0177.0.0.1.
Credit where it is due, somebody thought hard about this one.
Detection: test behaviour, not version
Two separate findings make version based detection unreliable.
First, as noted above, serverInfo reports "version":"0.0.0" on both
builds, so there is no banner to read. Good luck writing that Nessus plugin.
Second, and this is the one that matters: the patched image binds loopback
inside the container, which makes it unreachable on the pod network by
default. Operators who containerise it have to deliberately set --bind-address
and MCP_AUTH_TOKEN to get a working deployment.
That is correct security design. It also creates real pressure to widen the bind
again, and --allow-unauthenticated sits right there as a single flag that
restores the vulnerable posture on a fully patched build.
So a 0.9.0 deployment running --allow-unauthenticated --bind-address 0.0.0.0
is exactly as exposed as an 0.8.0 one, version alone tells you nothing, and
any survey has to test behaviour instead.
lab/fingerprint.sh does that in two steps, quiet identification first and then
one definitive request:
# 1. quiet identification, no JSON-RPC sent
OPT=$(curl -s -m 6 -X OPTIONS "$U")
if [[ "$OPT" == "DELETE, GET, HEAD, POST" ]]; then
echo " [+] MCP Streamable HTTP endpoint present"
else
echo " [-] not an MCP /mcp endpoint"; exit 1
fi
# 2. definitive test, one unauthenticated initialize
# ... [REDACTED: request body] ...
# verdict keys on whether an mcp-session-id is issued
The verdict logic is the useful part and needs no redaction:
If an unauthenticated
initializereturns anmcp-session-id, the instance is exposed. If it returns401withWWW-Authenticate: Bearer, or nothing at all, it isn’t.
Port 3000 is the default rather than a requirement. It is a yargs option and
the container EXPOSEs it, so it is the correct first guess and a terrible
assumption.
If you are defending: this is a one request check you can run against your own inventory this afternoon. It is also about fifteen minutes of work away from being a nuclei template, which as of writing nobody has published.
Not exposed, or just not indexed?
Earlier we said exposure density is poor, and we based that on the usual reflex, which is to go and look at Shodan and Censys and count how many of these things are sitting in the open. The answer was “not many”. The number is probably fine. The inference we drew from it was not, and it is the same inference defenders use to conclude that they are fine.
A correction, starting with one of ours
Censys and Shodan are not stateless. They complete full TCP handshakes, negotiate TLS, and run protocol-aware probes rather than spraying packets and hoping. Stateless is the word for masscan, which builds its own packets, never opens a real socket, and can sweep enormous address ranges quickly precisely because it is keeping track of nothing.
So statelessness is not why the internet scanners miss this. The reason is simpler and more annoying.
Their generic HTTP probe is effectively a GET /. argocd-mcp does not answer a GET /. It answers a POST to /mcp, carrying Content-Type: application/json, an Accept header containing both application/json and text/event-stream, and a JSON-RPC initialize body wrapped around the lot. Miss any part of that and you get a 406 or a 400, which in a bulk dataset looks exactly like the thousand other things that returns a 406.
And what does a GET / get you? Express. That is the entire banner. No product name, no version, nothing that suggests an Argo CD control plane is behind the door. Recall also that even when you do speak the protocol correctly, serverInfo cheerfully reports "version":"0.0.0". Port 3000 is generic Node, shared with half the ecosystem and with Grafana’s default, so the port number narrows nothing either.
There is no banner here to index, so a search engine built on banners will find nothing to index. Which means an empty Censys result is a statement about how Censys probes, and not a census of what exists.
The shape of the search that would work
The naive version of this pipeline is masscan across a country’s address space and then httpx to narrow whatever comes back. That shape is broadly right, and it needs two corrections before it would work.
masscan is genuinely the correct first stage. Stateless SYN scanning at country scale is a solved problem, it needs raw socket access and a sensible rate limit, and what comes back is a list of addresses with a port open. That is all it is, though. Its optional banner grabbing is crude, it will not speak JSON-RPC for you, and “port 3000 is open” describes a colossal number of Node applications that have nothing whatsoever to do with Argo CD.
httpx is the correct second stage, with the caveat that a default httpx run will not find this either, because httpx defaults to GET and would dutifully report a 404 page title for every one of these. It does support custom methods, headers, request bodies and response matchers, so it can be made to carry the right probe. It just will not do it on its own.
Put that together and the working shape is three stages rather than two: a stateless sweep to find open ports, an HTTP pass to find which of those are actually speaking HTTP, and then one application-layer probe that speaks MCP properly and keys the verdict on whether an mcp-session-id comes back. Which is, you will notice, exactly what fingerprint.sh does against a single host in the detection section above. The only difference between our lab check and a country-scale hunt is the size of the input list.
We are deliberately not publishing that pipeline assembled and tuned, for the same reason the exploit above is redacted. The point being made is about methodology, not about handing anyone a starting gun.
The scale objection, which does not survive arithmetic
Whenever this comes up, somebody reaches for the reassuring version: sure, in theory, but nobody is going to scan an entire country looking for a niche MCP server. There are hundreds of millions of addresses. It would take forever.
It would not. Let us do the numbers using Japan as an example, because the numbers are the argument.
Two units, before the tables. Scanning speed is quoted in pps, packets per second, which is simply how many probes you can push onto the wire each second. A million of them is an Mpps. Link speed is quoted separately, and GbE just means Gigabit Ethernet: 1GbE is a one-gigabit-per-second network interface, 10GbE is a ten-gigabit one. The two are related but not interchangeable, because what limits a sweep is usually how many individual packets the machine can emit rather than how many bits the link can carry. Tiny packets exhaust the first budget long before the second.
Which brings us to packet size, where the commonly quoted figure is wrong in a direction that flatters the attacker. A bare TCP SYN is 60 bytes as a frame, and that is the number you will see repeated. What actually occupies the wire is 84 bytes, because Ethernet adds a 4-byte frame check sequence, an 8-byte preamble, and a 12-byte interframe gap that exists precisely so the next packet is not stepping on this one. Every figure below uses 84.
That correction matters for one specific reason: it sets a hard physical ceiling. A 10GbE link carrying minimum-size frames tops out at 14.88 Mpps, and no amount of tuning moves it, because the limit is the wire and not the software.
190,000,000 addresses, one port, 84-byte probes
100k pps commodity VPS, no tuning 1900 s ~32 minutes
1M pps tuned NIC, gigabit link 190 s ~3 minutes
10M pps PF_RING + 10GbE 19 s ~19 seconds <- masscan's practical max
14.88M pps 10GbE line rate 13 s <- physical ceiling, unreachable
outbound traffic, single pass 15.96 GB
with 3x retransmit for reliability 47.9 GB
So the honest range is somewhere between twenty seconds and half an hour, depending entirely on what you are scanning from. The 10 Mpps figure needs kernel bypass and a 10GbE link, so treat it as a headline rather than a plan; the line-rate row is there to show that even the headline has a hard stop just above it. The thirty minute figure needs a cheap virtual machine and no expertise whatsoever, and that is the row that should worry you.
And the bandwidth, which is the part people find genuinely surprising: the entire sweep of Japan costs about 16 GB outbound, call it 48 GB if you retransmit properly. That is roughly one feature film in 4K. You are not renting a datacentre for this, you are renting the smallest instance your provider sells.
Japan is the expensive example
Japan is a deliberately unflattering choice for the attacker, because it was early to the table. It holds roughly 189 million IPv4 addresses for 124 million people, an allocation per head that only a handful of countries match, and which no country entering the internet after about 1995 was ever going to get. Pick a later entrant and the numbers stop being interesting in a hurry.
Myanmar has somewhere between 174,000 and 283,000 IPv4 addresses depending on whose aggregation you trust. Take the high end:
addresses one port @ 15k pps traffic
Japan 189,000,000 ~3.5 hours 16 GB
Myanmar 283,000 ~19 seconds 23 MB
Nineteen seconds, at the deliberately polite rate chosen above to avoid packet loss and mitigation triggers. At a lazy 100k pps it is under three.
And once a national address space is that small, the single-port constraint stops being a constraint. The top thousand ports across all of Myanmar is 283 million probes, about five hours at the same polite rate, which is to say it costs the same as asking Japan one question. The full 65,535-port sweep is a couple of days. At this scale an attacker is not forced into shallow-and-fast at all, and the comforting “we are a small market, nobody bothers with us” has the logic exactly backwards: a small allocation is cheap to scan exhaustively.
Two honest caveats. Allocated is not routed, and routed is not answering, so the live population is a fraction of the allocation in both countries; a recent internet-wide survey saw only around 24,000 Myanmar addresses respond to ICMP at all. And a country with thin international transit returns replies slowly, so masscan’s post-transmission wait is most of that nineteen seconds rather than an afterthought.
The space collapses before the expensive work starts
Here is the bit that defeats the intuition, though. Nobody ever does costly work on 190 million things. The address space collapses at the first stage, and every stage afterwards operates on a rounding error:
addresses probed 190,000,000 one SYN each, minutes
3000/tcp open ~38,000 masscan output
actually speaking HTTP ~22,800 httpx, minutes
MCP endpoint (OPTIONS /mcp) ~200 specific now
vulnerable (initialize -> session) ? one request each
Every number in that table below the first is invented. We did not run this, so treat them as illustrating a shape rather than reporting a measurement, and do not quote the two hundred back at me as though it were a finding.
The shape is the point, and the shape is four orders of magnitude of attrition in the first step. By the time anything expensive happens, by the time you are doing per-host HTTP with timeouts and retries, the list is tens of thousands, and httpx chews through tens of thousands of hosts in minutes at a few hundred concurrent requests. The final MCP probe runs against a list small enough to fit on a screen.
The whole pipeline, end to end, is an afternoon and pocket change.
What actually constrains it, since it is not compute
Being fair to the objection, there are real limits. They are simply not the ones people assume.
Your provider will notice, and faster than the abuse process suggests. It is tempting to model this as a human pipeline, where a target complains, an upstream reads the mail, and the scan is long finished by then. That is true of the paperwork and false of the network. Sustained single-port SYN sweeps are exactly what automated DDoS mitigation is built to spot, and plenty of hosts will null-route the source or suspend the instance within seconds of the pattern appearing, with the email arriving afterwards as a formality. The thirty-minute figure assumes nobody pulls the plug at minute two.
Speed costs accuracy. masscan at aggressive rates drops responses, so a single fast pass systematically under-reports. Anyone who cares about completeness scans slower or scans repeatedly, which trades the thirty minutes back for hours.
One port is doing a lot of work in that arithmetic. Every figure above assumes 3000/tcp, which is a yargs default and an EXPOSE line rather than a requirement. It is the correct first guess and a terrible assumption, and anyone who wants the instances that are not on it is scanning a handful of ports instead of one. Multiply accordingly: the comfortable half hour becomes most of a day, and the 16 GB becomes 160.
It is loud. Wide single-port sweeps light up network telescopes, darknet sensors and reputation feeds like a Christmas tree. Painless is not the same as invisible, and an actor who wants to stay quiet gives up most of the speed advantage described above.
None of which changes the conclusion. The comforting belief that scale is a defence, that the address space is so large nobody could be bothered, is simply false, and it has been false for about a decade. If your security position depends on nobody looking, it is not a security position. It is a hope.
Nobody starts from a country
Which is the moment to admit that the entire exercise above is a teaching device rather than a threat model.
Scan cost and target value are independent variables, and the cheap end of the first is usually the worthless end of the second. Nineteen seconds buys you every open port in Myanmar and a target list of approximately no commercial interest, because extortion economics are set by ability to pay. A ransom note is leverage against an organisation with cyber insurance, a regulator, a breach-notification obligation and a board that can authorise a wire transfer. Against an organisation whose honest reply is that if they had a quarter of a million dollars they would not be in this position, it is a badly spent afternoon. Same exploit, same three requests, entirely different expected value.
So real targeting does not start from a map. It starts from a victim profile and derives the address space afterwards, and the natural unit for that is the ASN rather than the country. Cloud regions publish their ranges for download. Hosting providers that serve a particular sector are identifiable and finite. An acquisition target has known netblocks. Pick three ASNs that carry financial services in a wealthy jurisdiction and you are sweeping perhaps two million addresses instead of a hundred and ninety, which at the polite rate is a minute of work.
That is better than a size reduction, because it is also a quality filter. A national allocation is mostly residential broadband, mobile carriers and government, none of which will ever run Argo CD. An ASN selection excludes all of that before a single packet is sent, so the list is smaller and denser. It also disposes of the loudness constraint that the previous section treated as real, since the abuse and telescope risk scales with fan-out, and a few hundred thousand addresses inside one hosting provider is indistinguishable from that provider’s ordinary background traffic.
The honest hierarchy, then, in descending order of cost and ascending order of likelihood:
country-scale sweep expensive, loud, low yield the worst case, and still an afternoon
ASN-targeted sweep minutes, quiet, high yield what a competent actor actually does
lateral movement free, invisible, highest yield what actually happens to this CVE
Each step down is cheaper, quieter and more productive than the one above it. The 190 million figure earns its place in this post precisely because it is the expensive option, and it still costs less than a night out.
Which is all beside the point, and that is the actual finding
Having spent a thousand words establishing that an attacker could build their own index of the internet for the price of a takeaway, it is worth saying plainly that for this bug, it would not be the best use of their time.
Go back to the container evidence. The 0.8.0 image was reachable on the Docker bridge with no published host ports at all, no -p, nothing mapped. Translated to Kubernetes, the natural home for this thing is a pod talking to other pods, on a network that no internet scanner and no country-scale sweep will ever touch. The most exposed instances of argocd-mcp are, almost by definition, the ones that are not on the internet.
So there are three populations here and only one of them is measurable. The indexed population is small, and we can say that with confidence. The internet-reachable population is unmeasured, though we now know roughly what it would cost somebody to measure it. The genuinely exposed population, the one sitting on flat pod networks where any compromised workload can reach it, is unmeasured by anybody and is the only one that determines your risk.
Defenders get the inference backwards, and it is an easy mistake to make. Querying Censys for your own estate, finding nothing, and concluding you have nothing is only valid if Censys would have seen it. For this bug class it would not. The check that settles the question is the one request from the detection section, run from inside your own network, against your own inventory, where the interesting instances live.
So we will restate the earlier claim more carefully. What is low is the indexed population. Everything else is unmeasured, and we have no way to measure it from here without scanning things that are none of our business. Absence of evidence, as the saying nearly goes, is evidence only of how you went looking.
No, the attacker never steals the token
A natural assumption at this point is that exploitation means capturing the operator’s Argo CD token and then replaying it. It does not, and the reality is worse. We needed to reiterate this as someone might still get confused (if you aren’t, good).
The attacker never sees that token. It lives in the MCP server’s environment and
travels only outbound, to Argo CD. In our lab the sink recorded
Authorization: Bearer TEST-ARGOCD-TOKEN purely because the sink was standing in
as the configured Argo CD backend, and that is server-to-backend traffic. Going
back through every client-facing capture we took, the initialize response, the
tools listing, the read call, container and npm alike, the token string appears
in precisely none of them. The attacker’s side of the connection receives no
credential at any point.
So there is no theft step and no second handshake. The attacker uses the token by proxy, without ever learning its value:
1. initialize -> 200 + mcp-session-id <- vulnerability confirmed here
2. notifications/initialized -> handshake, not an auth step
3. tools/list -> 15 tools
4. tools/call -> server attaches the operator's token on the way out
This is a confused deputy in the textbook sense. The server holds the authority and applies it on behalf of whoever asks, so stealing the credential would be a waste of effort when the server will happily spend it for you. The distance from “confirmed vulnerable” to “workload running in the cluster” is three more requests and zero additional access. No escalation stage, nothing to crack, no second exploit. That is what a 10.0 looks like from the outside.
It also means the probe is not as innocent as it feels. A GET / reads what a
service volunteers about itself. An unauthenticated initialize sends a
protocol-level request the server acts on, and allocates server-side session
state for, which is precisely why the session ID has to be echoed back on
every subsequent request. You are not observing the service, you are transacting
with it and leaving something behind.
Against your own estate, that is an audit. Against somebody else’s, it is
unauthorized interaction with a live system, and stopping short of tools/call
is a matter of restraint rather than of category.
Keywords for the non-specialist
In the order you probably hit them.
Argo CD is a deployment tool that lives inside a Kubernetes cluster. It watches a Git repository of configuration files and keeps the running system matching what that repository says, so deploying becomes a matter of editing a file rather than running commands by hand.
GitOps is the name for that whole approach, where the Git repository is treated as the single authoritative description of what should be running, and software rather than a human is responsible for making reality agree with it.
Kubernetes, often shortened to k8s, is the system that runs containerised applications across a fleet of machines, deciding what runs where and restarting things that fall over. A cluster is one such fleet, and a pod is the smallest unit running inside it, usually one container doing one job.
MCP, the Model Context Protocol, is a standard way of handing an AI model a set of tools it can call. An MCP server is the adapter that exposes some real system, Argo CD in this case, as tools the model can invoke. A tool here just means one callable action, like “list the applications” or “start a sync”.
A token, in this context, is a long secret string that proves an identity to a system, functionally a password that software uses instead of a human. The entire bug is about which direction a particular token was proving something in.
Authentication is checking who somebody is. An authentication bypass is a flaw that lets somebody skip that check entirely, which is what happened here. Unauthenticated or pre-auth means an attacker needs no account, no password and no prior access, they simply need to be able to reach the service over the network, which is what makes this class of bug so serious.
CVE is the global naming scheme for publicly known vulnerabilities, so CVE-2026-82456 is just this bug’s permanent id. CVSS is the accompanying severity score from 0 to 10, where 10 is as bad as the scale goes, and this one scored 10.
An advisory is the official write-up a vendor or maintainer publishes describing a vulnerability and its fix. GHSA is GitHub’s version of that, and NVD is the US government database that catalogues them all.
Patch diffing is the technique this post is built on, where you take the vulnerable version and the fixed version of a piece of software and compare them line by line, on the reasonable assumption that whatever the vendor changed is a decent map of what was broken.
A PoC, or proof of concept, is code demonstrating that a vulnerability is real rather than theoretical. It is not the same thing as a weaponised exploit, which is a polished tool built for use against other people’s systems, and the distinction is why parts of the code in this post are redacted.
Binding and loopback describe where a network service is willing to
accept connections. Binding to loopback, written 127.0.0.1 or localhost,
means only programs on the same machine can connect. Binding to 0.0.0.0, which
this software did, means anything that can reach the machine can connect.
DNS rebinding is the trick explained in the back half of this post. A
malicious website tells your browser that its own domain lives at 127.0.0.1,
and then uses your browser as a way to talk to services on your own machine that
were never meant to be reachable from the internet. It is why binding to
loopback is not the safety net people assume it is.
A container is a packaged, self-contained copy of an application and everything it needs to run, so it behaves identically wherever it is started. Docker and ghcr.io are, respectively, the common tool for running them and one of the common places to download them from.
An endpoint is a single address on a web service that accepts requests, such
as /mcp here. HTTP status codes are the numeric replies a service gives
back, and only three matter for this post: 200 means it worked, 401 means
you were refused for lack of valid credentials, and 406 means the request was
malformed in a way the server would not accept.
Shodan and Censys are search engines for internet-connected machines. They continuously scan the public internet, record what each address is running, and let you search the results, which is how researchers estimate how many vulnerable systems exist in the wild.
masscan is a tool for scanning very large ranges of internet addresses very quickly to find which ones have a given port open. It is called stateless because it fires off its probes without keeping track of the conversations, which is what makes it fast and also what makes its results shallow. Each probe is a SYN packet, the tiny opening message of any network connection, and scanning speed is quoted in pps, packets per second. httpx is a companion tool that takes a list of addresses and works out which are actually running a website or web service, and what that service looks like.
A banner is whatever identifying text a service volunteers when you connect to it, often a product name and version. Scanning engines catalogue banners, so software that volunteers nothing is largely invisible to them, which is a recurring theme in this post.
A VPS, or virtual private server, is a rented computer in somebody else’s datacentre, available by the hour for a few dollars and deleted just as easily. Bulletproof hosting refers to providers who deliberately ignore complaints about what their customers do, which is a service people pay a premium for.
A residential proxy routes traffic through ordinary consumer internet connections so that it appears to come from a normal home user rather than a datacentre. It carries web traffic only, which is why it cannot be used for the raw packet scanning described above.
An ASN is the identifier for a network operator, so saying traffic came from a residential ASN is a way of saying it came from a consumer ISP rather than a hosting company.
A confused deputy is the name for this whole category of flaw, where a program that legitimately holds some power can be tricked into using it on somebody else’s behalf. The attacker never acquires the privilege themselves, they simply get the program holding it to act for them, which is why no password or token ever needs to be stolen.
A sink, in the lab sense used here, is a fake server that stands in for a real one. It logs whatever is sent to it and replies with invented data, which lets you observe how software behaves without ever touching a real system.
References
- NVD, CVE-2026-82456
- GitHub Security Advisory, the authoritative reproduction, GHSA-rp45-5x3v-48mr
- VulnCheck, argocd-mcp 0.8.0 authentication bypass via unauthenticated HTTP
- Repository, argoproj-labs/mcp-for-argocd
- Releases, v0.9.0 release notes
- Containers,
ghcr.io/argoproj-labs/mcp-for-argocd:0.8.0vulnerable,:0.9.0patched
All testing was performed against locally installed builds on an isolated workstation, and no third party system, Argo CD instance, or Kubernetes cluster was contacted at any point. Write tools were enumerated and never invoked, and the exploit code is published in redacted form, with the upstream advisory remaining the authoritative reproduction.