Cyl created ZOOKEEPER-5025:
------------------------------

             Summary: Operational Log Forgery via Newline Injection in 
EnsembleAuthenticationProvider
                 Key: ZOOKEEPER-5025
                 URL: https://issues.apache.org/jira/browse/ZOOKEEPER-5025
             Project: ZooKeeper
          Issue Type: Bug
          Components: server
    Affects Versions: 3.9.2, 3.6.0
            Reporter: Cyl
         Attachments: poc_simple.py

h3. Summary

An unauthenticated attacker can inject arbitrary fake log lines into 
ZooKeeper's operational log by sending a crafted {{add_auth("ensemble", ...)}} 
request containing newline characters ({{{}\n{}}}). When the ensemble name 
doesn't match, {{EnsembleAuthenticationProvider.handleAuthentication()}} logs 
the raw, unsanitized name via {{{}LOG.warn(){}}}. Because SLF4J's {{{}}} 
placeholder preserves embedded newlines, the attacker can forge complete log 
entries — with arbitrary timestamps, log levels, class names, and messages — 
that are visually indistinguishable from genuine ZooKeeper log output.

I found this issue while investigating the root cause pattern of ZOOKEEPER-3979 
("Clients can corrupt the audit log"). ZOOKEEPER-3979 addressed a similar 
CWE-117 issue in the *audit log* via digit auth usernames, but the same class 
of unsanitized-input-to-log-output problem exists in 
{{EnsembleAuthenticationProvider}} targeting the *operational log* — a 
completely separate code path and sink that was not covered by the 
ZOOKEEPER-3979 fix.
h3. Details

When ZooKeeper is configured with {{EnsembleAuthenticationProvider}} (used in 
multi-tenant/multi-cluster deployments per the [admin 
guide|https://zookeeper.apache.org/doc/current/zookeeperAdmin.html]), the 
provider logs a warning for any unrecognized ensemble name. The problem is at 
{{EnsembleAuthenticationProvider.java}} line 80 and 95:
{code:java}
// code placeholder
// EnsembleAuthenticationProvider.java:80
String receivedEnsembleName = new String(authData, StandardCharsets.UTF_8);
// ↑ Raw client bytes → String, newlines preserved

// EnsembleAuthenticationProvider.java:95
LOG.warn("Unexpected ensemble name: ensemble name: {} client ip: {}",
         receivedEnsembleName, id);
// ↑ SLF4J {} does toString() substitution — does NOT escape \n {code}
The data flow is straightforward — single request, single thread, no 
intermediate storage:

 

{{Client TCP:2181 → NIOServerCnxn.readRequest()
  → ZooKeeperServer.processPacket(cnxn, h=\{type=100}, request)
  → AuthPacket: scheme="ensemble", authData=<attacker-controlled bytes>
  → ProviderRegistry.getServerProvider("ensemble")
  → EnsembleAuthenticationProvider.handleAuthentication(cnxn, authData)
    → receivedEnsembleName = new String(authData)   // contains \n
    → ensembleNames.contains(receivedEnsembleName)   // false
    → LOG.warn("... {} ...", receivedEnsembleName)    // \n output verbatim
    → Logback PatternLayout writes line break → forged log line appears}}

The key issue: SLF4J's parameterized logging ({{{}{}{}}}) calls {{.toString()}} 
on the argument and inserts it as-is. Logback's {{PatternLayout}} does not 
escape control characters in the message body. So a {{\n}} inside the 
substituted value results in a genuine line break in the log output, and 
everything after it appears as a new, independent log entry.

This is distinct from ZOOKEEPER-3979 in several ways:
||Dimension||ZOOKEEPER-3979 (Audit Log)||This Issue (Operational Log)||
|Target log|Structured audit log (tab-separated)|SLF4J/Logback operational log|
|Injection char|{{\t}} (tab) — forges fields within a line|{{\n}} (newline) — 
forges entire new log lines|
|Code path|{{DigestAuthenticationProvider}} → {{AuthUtil}} → 
{{AuditEvent}}|{{EnsembleAuthenticationProvider}} → {{LOG.warn()}}|
|Trigger|Requires a second write operation after {{add_auth}}|{{add_auth}} 
alone triggers the injection immediately|
h3. PoC

{*}Prerequisites{*}: Docker, Python 3 (standard library only — no ZooKeeper 
client library needed)

*1. Start ZooKeeper with EnsembleAuthenticationProvider enabled:*

 
{code:java}
// code placeholder
mkdir -p /tmp/zk-ensemble-poc && cat > /tmp/zk-ensemble-poc/docker-compose.yml 
<< 'EOF'
name: zk-ensemble-log-injection
services:
  zookeeper:
    image: zookeeper:latest
    container_name: zk-ensemble-inject
    ports:
      - "32181:2181"
    environment:
      SERVER_JVMFLAGS: >-
        
-Dzookeeper.authProvider.1=org.apache.zookeeper.server.auth.EnsembleAuthenticationProvider
        -Dzookeeper.ensembleAuthName=production-cluster
EOF
cd /tmp/zk-ensemble-poc && docker compose up -d && sleep 10 {code}
 

 

*2. Run the exploit (pure Python — no dependencies):*

run `poc_simple.py`

 

*3. Verify the injected log lines:*

 

{{docker logs zk-ensemble-inject 2>&1 | grep -E "SECURITY ALERT|superDigest 
authentication bypassed"}}

Expected output — these lines do NOT exist in ZooKeeper's actual codebase:

 

{{2026-03-08 21:45:00,000 [myid:1] - ERROR [main:ZooKeeperServer@999] - 
SECURITY ALERT: Unauthorized admin access detected from 10.0.0.1
2026-03-08 21:45:00,001 [myid:1] - WARN  [main:ZooKeeperServer@999] - Session 
0xDEADBEEF: superDigest authentication bypassed client ip: 192.168.48.1}}

*4. Cleanup:*

 

{{cd /tmp/zk-ensemble-poc && docker compose down -v}}
h3. Log of Evidence

Full server log around the injection point (from {{{}docker logs 
zk-ensemble-inject{}}}):

 

{{2026-03-08 13:48:56,697 [myid:] - INFO  
[NIOWorkerThread-4:o.a.z.s.ZooKeeperServer@1699] - got auth packet 
/192.168.48.1:39104
2026-03-08 13:48:56,698 [myid:] - WARN  
[NIOWorkerThread-4:o.a.z.s.a.EnsembleAuthenticationProvider@95] - Unexpected 
ensemble name: ensemble name: fake-ensemble
2026-03-08 21:45:00,000 [myid:1] - ERROR [main:ZooKeeperServer@999] - SECURITY 
ALERT: Unauthorized admin access detected from 10.0.0.1
2026-03-08 21:45:00,001 [myid:1] - WARN  [main:ZooKeeperServer@999] - Session 
0xDEADBEEF: superDigest authentication bypassed client ip: 192.168.48.1
2026-03-08 13:48:56,699 [myid:] - WARN  
[NIOWorkerThread-4:o.a.z.s.ZooKeeperServer@1729] - Authentication failed for 
scheme: ensemble}}

Lines 3-4 are {*}attacker-injected{*}. They appear as genuine {{ERROR}} and 
{{WARN}} entries from {{ZooKeeperServer@999}} (a line number that doesn't exist 
in real code). The format — timestamp, thread, class, level — matches real 
ZooKeeper log output exactly.

Note: {{client ip: 192.168.48.1}} appended to line 4 is the residual text from 
the original {{LOG.warn}} template's second {{{}}} placeholder being filled 
with the real client IP. An attacker can account for this by ending their 
payload with a string that absorbs the suffix cleanly.



--
This message was sent by Atlassian Jira
(v8.20.10#820010)

Reply via email to