Kafka Connect, Windows FileStream classpath leftover
KAFKA-13748 took FileStream off the Unix Connect CLASSPATH. The Windows launcher still loads connect-file from libs/*. Unauth REST can read and append files the Connect UID can touch. Labbed 4.3.1. No CVE yet.
- Name
- Kafka Connect, Windows FileStream classpath leftover
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Apache Kafka
- Affected
- Apache Kafka Connect 4.3.1 (26b251a) on Windows shipped kafka-run-class.bat. Unix connect-distributed.sh already excludes connect-file. Default Linux broker / Docker is not this leftover.
- Published
- 06 Oct 2026
- Updated
- 06 Oct 2026
- Tags
- 0-day, kafka, kafka-connect, filestream, cwe-552, cwe-1188, unauthenticated
The .bat never got the memo
I am @abraxas_null. The proof of concept is on GitHub: abraxas/kafka-connect-filestream-win (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
This is Apache Kafka Connect 4.3.1 (26b251a451ce941d3d7a55e6487bcb7f16b5ad48). No CVE yet. CWE-552 / CWE-1188. 9.1 High (CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N). Windows shipped launchers. Unix .sh already dropped the jar.
In 2022, Kafka Connect shipped two demo connectors that open whatever path you put in a JSON field. Someone noticed. Unix launchers stopped putting those jars on the CLASSPATH. The Windows launcher kept concatenating libs\*. Four years and a major version later, the tarball still ships the jar, the .bat still loads it, REST is still unauthenticated, and FileStream still has no path allowlist. The docs say the connectors are not on the default CLASSPATH. That sentence is true for one operating system.
Why Kafka
I had just finished unpublished Crit/High hunts of Grafana OSS, Prometheus, and PostgreSQL. Default installs came back empty. The leftovers that survived were config-gated: signup flags, remote-write auth on redirects, a client backup tool following absolute symlink targets. Kafka was next because it is a large Java tree with a security model that looks like a hole until you read the docs, and because Connect has a history of "demo" code that talks to the filesystem.
Kafka's model is honest. security.protocol=PLAINTEXT and no authorizer.class.name means anyone who can reach the broker can produce and consume. That is intended. A finding on this hunt needed something else: ACL or SASL skip when auth is on, unauth mutating admin when auth is configured, deserialization RCE, log-dir traversal, a secret leak, or a leftover of a published Connect/JAAS/OAuth fix that still fires on the pin.
I only wanted Critical or High. Local is in-scope. Medium stays on the floor unless it chains. Confluent Platform, kafka-ui, Strimzi, and Cruise Control are other trees.
Pinning 4.3.1
Latest GA, not an RC. GitHub tags offered 4.4.0-rc3 and 4.3.2-rc0. Those wait. 4.2.2 had a later calendar date and an older minor. Downloads listed 4.3.1 first. That is the pin.
The tag object a07059eb is not a commit. Git will tell you, with the patience of a librarian who has had this conversation before. HEAD landed on 26b251a451ce941d3d7a55e6487bcb7f16b5ad48, "Bump version to 4.3.1". gradle.properties says version=4.3.1. ZooKeeper is gone. KRaft is the broker. Connect is still in the same tarball, still a separate process, still started with a different script.
The skip list is half the hunt
Before you look for leftovers, you write down what is already public so you do not "discover" a CVE with a press release. Kafka's list lives at kafka.apache.org/community/cve-list. On 4.3.1 the interesting ones were already closed:
- CVE-2026-33557 DefaultJwtValidator accepting any JWT. Production handler fail-fasts without JWKS and sets
DISALLOW_NONE. - CVE-2026-33558 NetworkClient DEBUG dumping SASL bytes. Masked.
- CVE-2026-35554 producer buffer-pool race. Inflight gate is in.
- CVE-2025-27819 / CVE-2025-27818 JAAS Jndi/Ldap RCE. Default denylist.
- CVE-2025-27817 OAUTHBEARER token/jwks URL file-read and SSRF. Allowlist default empty.
- CVE-2024-56128 SCRAM nonce. Checked.
- CVE-2024-31141 ConfigProvider file/env from untrusted client config. Residual if you leave the JVM property unset. Published. Not this post.
- CVE-2024-27309 ZK to KRaft ACL during migration. ZK is gone.
- Older: Connect JNDI, unauth OOM, idempotent produce ACL bypass, fetch impersonation, SASL impersonation, Connect REST secrets in logs.
CVE-2026-41115 is a docs disagreement about CONSUMER_GROUP_DESCRIBE using DESCRIBE versus READ. The implementation uses DESCRIBE. I left it.
OAUTHBEARER is off unless you turn it on. sasl.enabled.mechanisms defaults to GSSAPI. Unsigned alg=none is the documented KIP-255 non-production callback, which is a different class from DefaultJwtValidator.
Twenty-five surfaces, one leftover
I split the tree. ACL APIs, SASL/OAuth, JAAS/JNDI, Connect REST, ConfigProviders, producer buffer race, protocol unauth, KRaft controller, group and share coordinator, transactions, replica Fetch, Connect plugins, Streams, SSL/TLS, Admin AlterConfigs, log storage paths, NetworkClient, delegation tokens, 4.3-only KIPs, serde and compression, MirrorMaker 2, principals and quotas, tools and scripts, StandardAuthorizer, inter-broker Envelope.
Most of that is Kafka doing what Kafka said it would do.
Envelope is controller-listener only and wants CLUSTER_ACTION before the inner principal is used. Fetch with a follower replicaId still needs ClusterAction; replicaId == -3 reads unreplicated bytes on topics you can already READ. Share groups need GROUP READ. Streams does not turn record keys into filesystem paths. SSL hostname verification defaults to https. The Unix wrappers quote "$@" into Java. JMX is off until JMX_PORT is set. MirrorMaker 2 dedicated REST is off. Delegation tokens stay off until you set a secret key.
The default broker is KRaft, PLAINTEXT, no authorizer, Connect not started. Unauth produce and consume is the product. I am not filing that.
Connect REST is where the temperature changed.
FileStream was supposed to be a lesson
Kafka Connect has a REST API. Default listen is http://:8083. Default rest.extension.classes is empty. The Apache docs treat that API as an admin surface you put on a trusted network. Plenty of people put it on a network and then forget which one.
In the beginning, the distribution also shipped FileStreamSourceConnector and FileStreamSinkConnector. They are teaching connectors. They are also a file API. Source opens Paths.get(filename) and produces lines onto a topic. Sink opens the same kind of path with CREATE|APPEND and writes record values as lines. There is no allowlist. There is no "must be under the worker's data dir." The filename is a string in the connector JSON.
Doyensec wrote that down in 2022. Unauth REST plus FileStream on the default CLASSPATH is arbitrary file read and write as the Connect UID. KAFKA-13748 / PR 11908 was the response: stop putting those jars on the CLASSPATH and plugin.path by default. Keep shipping the package so a quickstart can add it on purpose. Unix kafka-run-class.sh grew a regex:
regex="(-(test|test-sources|src|scaladoc|javadoc)\.jar|jar.asc|connect-file.*\.jar)$"The gradle-from-source loop also dropped file from cc_pkg. Docs grew a sentence: before the quickstart, add connect-file-*.jar to plugin.path, because these connectors are not included by default. Quickstart even uses a stale 4.3.0 in that filename on a 4.3.1 tree, which is a different kind of leftover and not a security bug.
That is a real fix. On Linux. On Docker. On every worker started with bin/connect-distributed.sh.
Then I opened the Windows copy
bin/windows/connect-distributed.bat does almost nothing. It sets a log4j path and calls kafka-run-class.bat. The Windows launcher builds a CLASSPATH by concatenating jars. For a source checkout it walks Connect modules:
for %%p in (api runtime file json tools) do (
for %%i in ("%BASE_DIR%\connect\%%p\build\libs\connect-%%p*.jar") do (
call :concat "%%i"
)
)file is still in that list. For a binary tarball it does this:
rem Classpath addition for release
for %%i in ("%BASE_DIR%\libs\*") do (
call :concat "%%i"
)No filter. No connect-file regex. Every jar in libs/ goes on the CLASSPATH, including the one KAFKA-13748 was written to keep off it.
build.gradle still copies project(':connect:file') into libs/. The jar is supposed to be there. Operators who want FileStream add it to plugin.path. Operators who start Connect with the shipped .bat get it for free.
plugin.discovery defaults to HYBRID_WARN: reflective scan plus ServiceLoader. FileStream ships META-INF/services for both source and sink. If the jar is on the CLASSPATH, REST lists it. POST /connectors with connector.class=org.apache.kafka.connect.file.FileStreamSourceConnector and a file field is enough.
Source:
stream = Files.newInputStream(Paths.get(filename));Sink:
outputStream = new PrintStream(
Files.newOutputStream(Paths.get(filename), StandardOpenOption.CREATE, StandardOpenOption.APPEND),
false,
StandardCharsets.UTF_8);I went back to the Unix script to make sure I was not hallucinating a Windows-only bug out of a Unix-only comment. The regex is still there. cc_pkg is still api transforms runtime mirror mirror-client json tools basic-auth-extension. No file. Two launchers, one security fix, one of them got the patch.
Two other hunts saw the same gap and scored it under the bar because they were looking at Linux Connect. That is a reasonable local call. The leftover is still High on the launcher Apache ships for Windows.
What I almost filed instead
Unauth Connect REST by itself is not a finding. The docs say it is an admin API. Filing "port 8083 has no password" is a product review, not a leftover.
MirrorMaker connectors stay on the Unix CLASSPATH. Unauth REST can create MirrorSourceConnector and point source.cluster.bootstrap.servers at a host you like. That is Kafka protocol to a chosen broker, not a file read. Below this bar.
connector.client.config.override.policy defaults to All. REST can set producer.override.sasl.jaas.config. JAAS Jndi/Ldap are denylisted. The leftover is "Connect REST is trusted," which Apache already wrote down, plus a 5.0 plan to default Allowlist. Not this bug.
CVE-2024-31141 still has a residual: unset automatic.config.providers allow-lists every ConfigProvider class, so connector JSON can mint FileConfigProvider. That is the published CVE plus a JVM property operators are supposed to set. I am not re-filing it.
Fetch replicaId == -3 is a cute ID. It is also an in-process replica-alter-log-dirs trick that still needs topic READ. Extra bytes are HW to LEO on topics you can already consume. No.
I thought about Wine. I thought about running kafka-run-class.bat under cmd.exe on a Windows VM. I thought about claiming apache/kafka:4.3.1 was the bug. Those are the wrong labs. The image is a Linux broker. The leftover is a launcher. The faithful Windows release path is "put every jar in libs/ on the CLASSPATH." That is one bash for loop. It is also exactly the .bat libs\* loop. Wine would have tested Wine.
plugin.path is the operator-intended way to load FileStream. Both lab workers leave it empty. If Unix had loaded FileStream, the control would have been contaminated and the run would be FAIL. That is in the PoC on purpose.
The lab
Host is macOS. Three containers.
Broker: apache/kafka:4.3.1, KRaft combined node, replication factor 1, advertised as kafka:9092 on the compose network.
Two Connect workers from the official kafka_2.13-4.3.1.tgz (SHA-512 pinned in the Dockerfile). Same image, different launchers.
connect-unix on 127.0.0.1:18083 runs stock bin/connect-distributed.sh. That is the KAFKA-13748 path.
connect-win on 127.0.0.1:18084 runs a ten-line wrapper that builds CLASSPATH from every file in libs/ and execs ConnectDistributed. Comment in the wrapper says what it is: unfiltered libs/* matching the Windows release launcher. Not Wine. Does not execute .bat.
Worker config on both: StringConverter, empty plugin.path, listeners=http://0.0.0.0:8083, no REST extension, distinct group ids and internal topics so they do not fight. Oracle volume only on the win worker: ./oracle to /oracle.
The PoC writes a witness string into secret.txt, waits for both REST /connector-plugins to answer, then:
- Unix plugins: FileStream absent. Six plugins, the Mirror family and friends.
- Unix
POST /connectorsFileStreamSource: 400Failed to find any class that implements Connector and which name matches org.apache.kafka.connect.file.FileStreamSourceConnector. - Win plugins: FileStream present. Ten plugins. The extra four are the two FileStream connectors under source and sink names.
- Win source POST 201 with
file=/oracle/secret.txt,topic=exfil. - Win sink POST 201 with
file=/oracle/pwned.txt,topics=exfil. - Poll
./oracle/pwned.txtuntil it containsKAFKA-CONNECT-FILESTREAM-WIN-WITNESS.
Source feeds sink through the topic. No extra producer. The witness crossing the worker UID is the oracle.
It also greps the tarball copies of both launchers so the last-run file records that Unix still excludes connect-file.*\.jar, Windows still concatenates api runtime file json tools, and connect-file-4.3.1.jar is sitting in libs/.
Last run:
unix-plugins n=6 filestream=absent
unix-post status=400
win-plugins n=10 filestream=present
win-source status=201
win-sink status=201
pwned bytes=37 has-witness=1
SUCCESS kafka-connect-filestream-win unix-filestream=absent unix-post=400 win-filestream=present win-source=201 win-sink=201 pwned=yes sim=unfiltered libs/* matching the Windows release launcher KAFKA-CONNECT-FILESTREAM-WIN-WITNESSReplay:
git clone https://github.com/abraxas/kafka-connect-filestream-win
cd kafka-connect-filestream-win/lab
./run.shBind it to loopback. The loopback client is lab/run.sh with a banner on it.
Wrong turns, in the order I made them
Pinning an RC. 4.4.0-rc3 looks like "latest." It is a release candidate. 4.2.2 looks newer on the calendar. It is an older minor. GA is 4.3.1.
Checking out the tag object. git switch 4.3.1 after a shallow clone can land on an annotated tag that is not a commit. The useful SHA is 26b251a.
Treating PLAINTEXT as a finding. I spent a morning confirming Produce and Fetch do not ask for SASL when no SASL is configured. Kafka already told everyone that.
Treating unauth Connect REST as the bug. REST without BasicAuth is the documented admin model. The bug is FileStream being reachable through that model after a fix that was supposed to make it unreachable.
Filing CVE-2024-31141 again. The automatic ConfigProvider path is still default-open. It is also the advisory. Operators have a JVM property. Different leftover.
Scoring Windows as "not High" because production is Linux. Production Kafka is mostly Linux. Apache still ships bin/windows/*.bat. A security fix that only landed in the .sh is an incomplete fix. The connect-plugins hunt called it "Not High (Windows leftover)." I called it High and then labbed it.
Wine. Running cmd.exe in a container to "really" execute the .bat tests the batch parser, JAVA_HOME quoting, and your patience with The input line is too long. The security-relevant line is for %%i in ("%BASE_DIR%\libs\*"). Recreating that loop on Linux is the Windows release tarball path. I wrote that into the SUCCESS line so nobody can pretend I booted Windows.
Putting FileStream on plugin.path for both workers. That proves FileStream still reads files, which Doyensec already proved in 2022. The leftover is CLASSPATH. Unix control has to 400.
Taking HTTP 201 on Unix as SUCCESS. If Unix accepts FileStream, the image is wrong or the exclude regressed. FAIL. The PoC fails closed on that.
A reverse shell. Theatre. The oracle is a witness string in a sink file the worker created.
Claiming the default broker is this bug. kafka-server-start does not start Connect. apache/kafka:4.3.1 is a broker image. This leftover is Windows Connect with the shipped launchers. Default broker hunt is still NONE.
What an attacker can do
Reach Connect REST on a worker started with bin/windows/connect-distributed.bat or connect-standalone.bat. Those call kafka-run-class.bat. REST is unauthenticated unless someone set rest.extension.classes. Default listen is http://:8083.
POST /connectors with FileStream source and a file of your choosing. The worker reads that path as the OS user it runs as and produces the bytes onto a topic. POST a FileStream sink with another file. The worker creates or appends that path.
Read a config file. Read a keystore password file if it is on disk in plaintext. Append a line to a writable location the service account owns. You get the Connect UID, not SYSTEM for free, and you do not get a new jar onto the CLASSPATH. You do get the filesystem the UID can already see, through a JSON POST, with no login.
Same JSON against stock Unix connect-distributed.sh is a 400. Linux Docker default is not this leftover. An operator who adds connect-file to plugin.path on Linux is following the quickstart. That is a choice. The .bat is not a choice. It is the launcher.
The fix
Copy the Unix exclude into kafka-run-class.bat. Drop file from for %%p in (api runtime file json tools). Keep shipping connect-file-*.jar in libs/ so people who want the demo can put it on plugin.path. Update the docs sentence so it does not claim a CLASSPATH fact that is only true on .sh.
Until then, do not start Connect on Windows with the shipped .bat if REST is reachable from anyone you do not already trust with the worker's files. Put rest.extension.classes on. Or start Connect the way the Unix scripts do, even on Windows: build a CLASSPATH that does not include connect-file.
I am not going to print a curl you can paste at someone else's :8083. The missing exclude is the useful part. If you own the worker, run the proof of concept against loopback.
References
- Proof of concept: abraxas/kafka-connect-filestream-win · kafka-connect-filestream-win-Abraxas-Labs.py
- Lab:
lab/· Dockerfile · docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-552 · CWE-1188
- Tree: apache/kafka 4.3.1 (
26b251a451ce941d3d7a55e6487bcb7f16b5ad48) ·kafka-run-class.bat·kafka-run-class.sh·FileStreamSourceTask.java·FileStreamSinkTask.java - KAFKA-13748 · PR 11908 · Doyensec 2022 FileStream advisory
- CVE list: kafka.apache.org/community/cve-list