BrickellTechnologies

Research

CVE-2026-105937: Reading Another Tenant's Messages Through an Apache Pulsar Shadow Topic

Elias Hasas · ·10 min read

Apache Pulsar is a distributed messaging and streaming platform, and multi-tenancy is one of the main reasons teams pick it. One cluster holds many tenants, each tenant has its own admins and namespaces, and the authorization layer exists to keep each tenant’s messages away from the others.

On 14 September we reported to the Apache security team that a single topic property got through that boundary. A tenant admin could create a topic in a namespace they own, set the reserved property PULSAR.SHADOW_SOURCE to a topic in any other tenant, and then consume that tenant’s stored messages from their own topic. The broker never asked whether the caller had any rights over the topic the property named.

The Pulsar team published it today as CVE-2026-105937, with the advisory on the users@pulsar.apache.org list.

  • Severity: CVSS 4.0 8.7 (High), CWE-862 Missing Authorization
  • Fixed in: Apache Pulsar 4.0.14, 4.2.5 and 5.0.0, released 5 October 2026
  • Credit: Elias Hasas, Mike Read and Omar Mousa, who each reported it independently

The advisory goes further than our report did. The same binding lets the attacker’s topic delete the other tenant’s stored data, which the Pulsar team worked out and we missed; more on that below.

Who is affected

Pulsar 3.0.0 through 4.0.13 and 4.1.0 through 4.2.4. There’s no fixed 4.1 release, so 4.1 users move to 4.2.5. Pulsar 2.x doesn’t contain the shadow topic code.

The attacker needs an authenticated principal that holds the tenant admin role on some tenant, because with the default authorization provider that’s what creating a topic or updating its properties requires. Nothing is needed on the victim’s side: no permission on the source topic, its namespace or its tenant. So the clusters at risk are exactly the ones Pulsar’s multi-tenancy is built for, where different teams, applications or customers each administer their own tenant on shared brokers. If the same operators administer every tenant, the bug gives them nothing they couldn’t already read.

The affected versions have no setting that turns shadow topics off. The broker picks the shadow code path purely because the property is present.

To check what your brokers run:

bin/pulsar-admin brokers version

How shadow topics work

Shadow topics arrived with PIP-180, for fan-out at a scale one broker can’t serve; the PIP’s motivating case is a broadcast topic with 100,000 subscriptions. A shadow topic is a read-only view of a normal persistent topic, its source, and it doesn’t copy any data. Its managed ledger points at the source’s BookKeeper ledgers, so the messages are stored once and each shadow serves its own subscribers from that storage.

PIP-180 put the access control on the source side. Users need PolicyOperation.WRITE on a new PolicyName.SHADOW_TOPIC policy to create or delete shadow topics, and the source topic’s setShadowTopics admin endpoint enforces that, which under the default provider means being a tenant admin of the source’s tenant.

On the shadow side, though, a shadow topic is just a topic carrying one reserved property, PULSAR.SHADOW_SOURCE, whose value names the source. The Java admin client’s createShadowTopic() shows this: it adds the property to a map and calls the ordinary create-topic endpoint. The helper reads the source’s partition metadata first, which an outsider isn’t allowed to do, but that’s a client-side step. The server has no dedicated “create shadow topic” endpoint where a source-side check could live.

How we found it

We followed the property from the REST API down to storage. File references are to the 4.1.1 source.

createNonPartitionedTopic in PersistentTopics.java binds a free-form Map<String,String> of properties from the request body and passes it through unchanged. The only authorization on the way, in PersistentTopicsBase, is validateNamespaceOperationAsync(..., NamespaceOperation.CREATE_TOPIC) against the namespace of the topic being created. That’s the attacker’s own namespace, so it passes.

BrokerService copies the map into the new topic’s ManagedLedgerConfig, where getShadowSource() reads PULSAR.SHADOW_SOURCE straight out of it. In ManagedLedgerFactoryImpl, that one value decides which class backs the topic:

return config.getShadowSource() == null
        ? new ManagedLedgerImpl(this, bk, store, config, scheduledExecutor, name, mlOwnershipChecker) :
        new ShadowManagedLedgerImpl(this, bk, store, config, scheduledExecutor, name, mlOwnershipChecker);

ShadowManagedLedgerImpl fetches the source’s managed ledger metadata, copies every one of the source’s ledgers into its own ledger map, and opens them in BookKeeper with the broker-wide ledger password. Nothing on that path reads the source topic’s own list of shadow topics or asks whether the caller has rights over the source.

There’s one more guard we expected to stop the read. After CVE-2021-41571, a cross-tenant read through the getMessageById admin API, ManagedLedgerImpl only serves a position if its ledger ID appears in the topic’s own ledger map:

} else if (ledgers.containsKey(position.getLedgerId())) {

That check is intact and does its job. The shadow has simply filled its own ledger map with the victim’s ledgers, so the guard treats them as the shadow’s and lets the reads through.

Reproduction

We re-ran the report today against the official apachepulsar/pulsar:4.1.1 image, in standalone mode with token authentication, the default PulsarAuthorizationProvider, and allowAutoTopicCreation=false. A superuser created two tenants with separate admin roles:

pulsar-admin tenants create victimtenant   --allowed-clusters standalone --admin-roles victim-admin
pulsar-admin tenants create attackertenant --allowed-clusters standalone --admin-roles attacker-admin
pulsar-admin namespaces create victimtenant/ns
pulsar-admin namespaces create attackertenant/ns

As victim-admin, we created persistent://victimtenant/ns/secret, published five messages to it, and unloaded the topic so its ledger closed. When we first tested this in September, messages still in the source’s open ledger weren’t visible to the shadow yet; they show up once the source rolls over to a new ledger.

The control comes first. As attacker-admin, every direct route to the victim’s topic is refused:

consume victimtenant/ns/secret   -> Client is not authorized to Get Partition Metadata
peek-messages ...                -> Unauthorized to validateTopicOperation for operation [PEEK_MESSAGES]
topics list victimtenant/ns      -> Unauthorized to validateNamespaceOperation for operation [GET_TOPICS]

Then the attack, one REST call that creates a topic in the attacker’s own namespace:

curl -X PUT -H "Authorization: Bearer $ATTACKER_TOKEN" \
     -H "Content-Type: application/json" \
     -d '{"PULSAR.SHADOW_SOURCE":"persistent://victimtenant/ns/secret"}' \
     http://localhost:8080/admin/v2/persistent/attackertenant/ns/shadow

HTTP 204

Consuming the attacker’s own topic returns the victim’s messages:

pulsar-client consume persistent://attackertenant/ns/shadow -s pwn -p Earliest -n 5

content:VICTIM-CONFIDENTIAL-MESSAGE-1
content:VICTIM-CONFIDENTIAL-MESSAGE-2
content:VICTIM-CONFIDENTIAL-MESSAGE-3
content:VICTIM-CONFIDENTIAL-MESSAGE-4
content:VICTIM-CONFIDENTIAL-MESSAGE-5

attacker-admin never held any permission on victimtenant.

Impact

The read is what we showed end to end. The attacker gets every message still retained in the source topic’s ledgers, which in practice is the topic’s whole retained backlog. The advisory notes one limit: messages protected with Pulsar’s client-side end-to-end encryption stay unreadable, because the attacker gets ciphertext without the keys.

The second leg is schema. A shadow topic resolves its schema ID to its source’s, and the broker registers a client-supplied schema during producer creation, before it refuses the producer’s sends. So the attacker can add a new schema version under the victim topic’s schema, subject to its compatibility settings. We traced that in the code but didn’t run it, and the advisory confirms it, adding that existing schema versions aren’t replaced.

The third leg is the one we didn’t find. A shadow’s managed ledger applies its own retention, trimming, offloading and deletion to the ledgers it adopted, and those ledgers belong to the source. According to the advisory, that can permanently delete the source topic’s data from BookKeeper and from tiered storage, whatever the source’s own subscriptions and retention policy say, including messages nobody has acknowledged yet. Deleting the shadow topic can delete the source’s ledgers as well. That part doesn’t even need an attacker; it can hit shadow topics an administrator set up on purpose.

What the advisory corrects in our report

Our report said there was no availability impact. We’d checked one route to it, whether the shadow’s recovery-open could fence the victim’s live ledger, found that it couldn’t, and stopped there without looking at what the shadow’s own retention does to the ledgers it borrowed. The Pulsar team did look, which is why the published vector carries VA:H and the title says “deletion of source topic data”.

We also said every release since 2.11.0 was affected. ShadowManagedLedgerImpl first ships in 3.0.0, which is where the advisory’s range starts.

We reported the non-partitioned create path; the advisory also covers creating partitioned topics and updating the properties of an existing topic, and the fix gates all three.

We scored it 9.6 under CVSS 3.1, treating the scope as changed because the impact lands in another tenant. The Pulsar team scored it 8.7 under CVSS 4.0 with no subsequent-system impact, and theirs is the published score.

The fix

Lari Hotari wrote two patches, both merged at the end of September.

PR #26739, reviewed by merlimat and dao-jun, adds a broker setting, enableShadowTopics, that defaults to false. While it’s off, the broker refuses to create shadow topics, refuses the PULSAR.SHADOW_SOURCE property, and won’t load or replicate to existing shadows. When it’s on, partitioned and non-partitioned creates and property updates all run a new check against the source topic’s tenant:

protected CompletableFuture<Void> validateShadowTopicTenantAsync(TopicName relatedTopic) {
    return topicName.getTenant().equals(relatedTopic.getTenant())
            ? CompletableFuture.completedFuture(null) : validateSuperUserAccessAsync();
}

A shadow in the same tenant as its source is allowed, since the caller already administers both. Anything crossing tenants needs a superuser, and the source-side setShadowTopics endpoint now runs the same check. Our report had suggested checking the caller against the source topic’s SHADOW_TOPIC policy, the way PIP-180 describes it. The shipped rule is stricter: across tenants, only a superuser.

PR #26746, reviewed by merlimat, closes the deletion leg. A managed ledger whose config or stored properties name a shadow source no longer owns its ledger data, so trimming keeps the source’s ledgers, offloading doesn’t run, and deleting the shadow removes only its own metadata. The shadow source is now stored in the managed ledger’s own properties, and the admin API answers a request to remove PULSAR.SHADOW_SOURCE, or set it to null, with 412 Precondition Failed.

We ran the same steps against 4.2.5 twice. With the shipped configuration, the attacker’s create request fails before anything gets bound:

HTTP 405  {"reason":"Shadow topics are disabled"}

With enableShadowTopics=true, the cross-tenant request is refused, and a shadow inside the victim’s own tenant, created by victim-admin, still works:

attacker-admin, source in victimtenant   -> HTTP 401  {"reason":"This operation requires super-user access"}
victim-admin,   source in victimtenant   -> HTTP 204

What to do

Upgrade

Move 4.0.x brokers to 4.0.14 or later, and 4.1.x or 4.2.x brokers to 4.2.5 or later. Pulsar 5.0.0 has the fix too. The project currently supports the 4.0.x and 5.0.x lines; the schedule is in its release policy.

If you use shadow topics, set enableShadowTopics=true in the configuration of every broker after upgrading. Without it, existing shadow topics won’t load. Shadows that cross tenants will then need a superuser to create.

Look for shadows you didn’t create

Any topic whose properties include PULSAR.SHADOW_SOURCE pointing at a topic in a different tenant is either a deliberate setup by an operator or this attack. The property shows up in:

bin/pulsar-admin topics get-properties persistent://<tenant>/<namespace>/<topic>

Upgrade before you delete anything you find. On affected versions, deleting a shadow topic can delete its source’s ledgers along with it; on the fixed versions, deleting a shadow removes only the shadow’s own metadata.

If you can’t upgrade yet

The advisory’s only mitigation is to grant the tenant admin role only to principals you trust. On these versions, tenant admin anywhere amounts to read access to every tenant’s retained messages, so treat it that way until the brokers are patched.

Disclosure timeline

  • 14 September 2026: reported to security@apache.org and the Pulsar PMC
  • 29 and 30 September 2026: PR #26739 and PR #26746 merged
  • 30 September 2026: the Pulsar team confirmed the report, said a fix was ready, and told us others had reported the same issue independently
  • 5 October 2026: Apache Pulsar 4.0.14, 4.2.5 and 5.0.0 released
  • 21 October 2026: CVE-2026-105937 published, with the advisory on users@pulsar.apache.org

Thanks to Lari Hotari for handling the report and writing both fixes, to merlimat and dao-jun for the reviews, and to Mike Read and Omar Mousa, who found the same bug on their own.

References

If you run a shared messaging platform and want to know whether one tenant can reach another’s data, that’s part of our penetration testing work.

Know what an attacker would reach first.

Tell us what you run and what worries you. We will come back with a scope, a fixed price and the earliest week we can start.