MINISTRY OF TINKERING — HM GOVERNMENT (UNOFFICIAL)  ///  CLASSIFICATION: UNCLASSIFIED  ///  REF: MoT-WEB-2026 // LONDON, ENGLAND  ///  ALL HARDWARE HANDLED IN ACCORDANCE WITH FIELD PROTOCOL  ///  NO DEVICES WERE HARMED IN THE PRODUCTION OF THIS WEBSITE  ///  STATUS: OPERATIONAL  ///  FIRMWARE BUILDS: NOMINAL  ///  SOLDER JOINTS: PENDING INSPECTION  ///  ALL UNATTENDED E-WASTE WILL BE DISASSEMBLED  ///  UNAUTHORISED ACCESS WELCOMED  ///      MINISTRY OF TINKERING — HM GOVERNMENT (UNOFFICIAL)  ///  CLASSIFICATION: UNCLASSIFIED  ///  REF: MoT-WEB-2026 // LONDON, ENGLAND  ///  ALL HARDWARE HANDLED IN ACCORDANCE WITH FIELD PROTOCOL  ///  NO DEVICES WERE HARMED IN THE PRODUCTION OF THIS WEBSITE  ///  STATUS: OPERATIONAL  ///  FIRMWARE BUILDS: NOMINAL  ///  SOLDER JOINTS: PENDING INSPECTION  ///  ALL UNATTENDED E-WASTE WILL BE DISASSEMBLED  ///  UNAUTHORISED ACCESS WELCOMED  /// 

Solving Zyxel's 36-Character Bootloader Challenge

Recovering the 36-character seed algorithm from zloader, and unlocking the debug flag on three models and three bootloader versions.

I Just Want To Unlock My Device

I have provided an online tool that generates the password from the required seed.

Abstract

Zyxel's second stage bootloader (zloader, ZHAL> prompt) guards its dangerous commands — flash writes, credential dumps — behind a Boot Module Debug Flag. Setting that flag requires answering a challenge:

ZHAL> ATSEget-seed <MODEL>          # prints a 36-character seed
ZHAL> ATENunlock 1,<password>     # password derived from that seed

Older Zyxel devices emit a 12-character seed whose algorithm has been public for years. Newer devices emit a 36-character seed, and that variant has been documented as unsolved since 2023.

This is the derivation, recovered from the bootloader binary and verified on hardware across three models and three bootloader versions, spanning four years of firmware.

Result: given one ATSEget-seed seed, the correct ATENunlock password is computable offline. The flag then exposes ATCKdump-creds, which prints the device's root and Wi-Fi credentials in cleartext.

What This Unlocks

When the Debug Flag is set, the full set of bootloader commands becomes available — the locked prompt offers eight, the unlocked one offers dozens. Most importantly that includes ATCKdump-creds, which prints three values in plaintext, labelled supervisor, admin and Wi-Fi PSK. They are read from the MRD (Manufacturer Related Data), a block of plaintext data that sits in the bootloader partition, and the router's OS seeds its accounts from them.

ATCKdump-creds gives no line labelled root, which is worth knowing before you try the output. The value it labels supervisor is the factory string seeding both root and supervisor accounts. On a factory unit it opens both. On the ISP-provisioned unit I tested the ISP had rotated supervisor and left root untouched, so the printed value no longer opened supervisor and still opened root — provisioning that looks like hardening does not close the path to root. If it fails against supervisor, try root before concluding it did not work.

Prior Work — What Was Already Known

Most of the surrounding functions of the bootloader and methods to extract the passwords are already public. The new discovery is the 36-character seed algorithm.

Already public Source
ATCKdump-creds prints psk/admin/supervisor hack-gpon, watchfulip
MRD at mtd0 0xFF00 holds plaintext creds drumpinicho/ex3301pass — but needs a shell first
Debug flag settable by ATRTwrite-ram RAM-write to 0x8402E9BE watchfulip, Dec 2025
12-character seed algorithm Kitz forums, long-standing
36-character seed algorithm nobody

watchfulip reaches the same flag by writing a fixed address with the RAM-test command. That works, but it is build-specific — and the address moves:

EX3301-T0     bootbase V1.45    MRD struct @ 0x8402e954
VMG8623-T50B  bootbase V1.49    MRD struct @ 0x8402e660
VMG8825-T50   bootbase V1.42    MRD struct @ never looked for it

That last line is the point. Solving the challenge–response is build independent: the third unit was unlocked without opening its bootloader at all, so its MRD address is still unknown here — it was never needed. The unlock is verified across three models with different hardware and three zloader versions four years apart.

Getting The Bootloader Binary

mtd0 is not a single blob. It is stage-1 bootbase, then the MRD, then an LZMA-packed stage 2:

0x00000-0x0FF00   bootbase (stage 1) — "Press 'x' or 'b' key..."
0x0FF00-0x10000   MRD block (plaintext credentials)
0x10000-...       LZMA: 5d 00 00 80 00, dict 8 MB, unpacked 0x3d2b0
import lzma
mtd0 = open('flash/mtd0_bootloader.bin','rb').read()
zloader = lzma.LZMADecompressor(format=lzma.FORMAT_ALONE).decompress(mtd0[0x10000:])
open('zloader.bin','wb').write(zloader)     # 250544 bytes; contains ATSEget-seed/ATENunlock/ZHAL

The most direct path to mtd0 would probably be by dumping the flash using an SPI-NAND flash reader. Since mtd0 includes the MRD in plaintext, the credentials can be trivially extracted from a dump.

I dumped mtd0 by catting the contents of /dev/mtd0 to netcat. Root came from a logset/syslog-ng config injection I vibe reversed in libzcfg_be.so, since patched (CVE-2026-6952, reported by William Honnér, fixed 2026-07-21); details out of scope here.

Disassembling The Bootloader

Finding The Load Base

The zloader binary is a raw MIPS image with no ELF header, section table or relocations. Ghidra will disassemble instructions at any base but without the correct load base no string ever attaches to the code that uses it resulting in no functions being readable.

The image tells you the base itself. MIPS instructions are 32 bits, so a 32-bit constant cannot fit in one: any absolute address is built as lui (top half) plus addiu (bottom half), and both halves sit in the file as plain bytes. The first such pair in the image is the entry stub

file 0x280:  3c1a83fb   lui   $k0, 0x83fb
file 0x284:  275a0290   addiu $k0, $k0, 0x290     ; $k0 = 0x83fb0290
file 0x288:  03400008   jr    $k0
file 0x28c:  00000000   nop
file 0x290:  3c1c8402   lui   $gp, 0x8402         ; <- lands here

It computes 0x83fb0290 and jumps there, and the instruction it lands on is at file offset 0x290. One address, one offset, same location:

base = 0x83fb0290 - 0x290 = 0x83fb0000

Once we know the Load Base we can disassemble correctly with Ghidra:

analyzeHeadless <proj> zyxel -import zloader.bin \
    -loader BinaryLoader -loader-baseAddr 0x83fb0000 \
    -processor MIPS:BE:32:default

The Password Generation Chain

Five functions matter — the two command handlers, and the three workers they share:

address role
FUN_83fcc3dccmd_atse ATSEget-seed handler
FUN_83fcc4d8cmd_aten ATENunlock handler
FUN_83fcc23cbuild_source builds the source string S from the MRD struct
FUN_83fd3da0encode_seed encodes S into the 36 characters ATSEget-seed prints
FUN_83fd408cderive_password transforms S into the password ATENunlock expects

Function Names

The raw image that was decompiled contained no ELF header or symbol table so Ghidra named the functions based on their entry points in the program's address space. This makes the code a pain to read. I still wanted to have the decompiled name available so every one of them on this page is a switch: click any symbol to swap it for a descriptive name, and click it again to swap back. Clicking one flips every occurrence of that same symbol, so you are not chasing nine FUN_83fb87b8snprintfs around. The control in the sidebar — at the foot of the screen on a phone — flips all of them at once.

This also works on the undefined data (DAT_8402e980model_string) and the bootloader commands because frankly they're also a pain to read.

Here's the full table with the friendly names and short descriptions:

In the postDescriptiveWhat it is
Command handlers
FUN_83fcc3dccmd_atseHandles ATSE — checks the model, draws the nonce, prints the seed
FUN_83fcc4d8cmd_atenHandles ATEN — re-derives the password and sets the flag
The three workers they share
FUN_83fcc23cbuild_sourceMRD struct + nonce → the source string S (stage 1)
FUN_83fd3da0encode_seedS → the 36 characters ATSE prints (stage 2)
FUN_83fd408cderive_passwordS → the password ATEN expects (stage 3)
libc, identified by their call signatures
FUN_83fb87b8snprintfEvery field of S is written by one of these
FUN_83fb7dc8strtoulHex parse, base 16
FUN_83fb9d4cstrlenBoth consumers call it on each field of S
FUN_83fb9fa8memsetZeroes the 37-byte output buffer
FUN_83fb94b0randThe nonce source
Data
DAT_8402e954mrd_structThe MRD copy in RAM — MAC, serial, the three small bytes
_DAT_8402e958saved_noncemrd+4. Non-zero doubles as "ATSE has been run"
DAT_8402e980model_stringThe unit's own model string, in the same BSS block
DAT_8402e9bedebug_flagThe Boot Module Debug Flag itself
Stack locals — the number is a frame offset
local_60MAGICThe three magic constants, written in as immediates at function entry rather than kept in .rodata. n % 3 picks one of them.
local_50h1S[0x0e:0x12] + S[0x00:0x04] — four serial characters then four MAC, 8 hex digits. strtoul'd into a.
local_44h2S[0x12:0x16] + S[0x04:0x08], the same shape one group along. Becomes b.
local_38h3S[0x16:0x1a] + S[0x08:0x0c]. Becomes c. Declared 16 bytes rather than 12 only because it is last and absorbs the frame padding.
local_54nibble2-byte scratch in *stage 3*: "%X" of the normalised S[0x11], copied straight back over it.
local_18nibbleThe same scratch buffer one stage earlier, inside FUN_83fd3da0. A different frame, so a different number.
Notation (not switchable)
x, y, z—Three single bytes read straight out of the MRD — mrd+0x6a, +0x6b and +0x69, in that order, not address order. Each lands in S as two hex characters, so each is 0-255. What they represent is not known; every unit tested reads 00 00 10.
w—The nonce, S[0x24:0x2a]. The only field that changes between calls.
n—x + y + z, so 0-765. Computed inside derive_password and never stored in S, so unlike x/y/z/w it cannot be read out of a printed seed. Used twice and for different things: n % 3 picks which magic constant, n & 3 picks how far mix is shifted to get the rotate count.
S—The source string: 0x2A bytes of NUL-separated ASCII, built from the MRD struct and the nonce. The 36 characters ATSE prints are called the *seed*; this is what the seed is an encoding of. Never printed and never typed — ATSE and ATEN each show a different view of it. Everywhere in this post S means this. The 'S' at the head of a serial number is just a character, and is written quoted to keep them apart.
Bootloader commands
ATSEget-seedPrints the 36-character seed
ATENunlockATEN 1,<password> sets the debug flag; ATEN 0 clears it
ATCKdump-credsPrints supervisor, admin and Wi-Fi PSK in cleartext
ATSHshow-statusShows the flag, and the model string ATSE wants
ATDCskip-model-checkDisables the model-name gate on ATSE
ATRTwrite-ramThe RAM write watchfulip used to set the flag directly

The Unlock Procedure

ATSEget-seed <MODEL> — FUN_83fcc3dccmd_atse

if (argc == 1 && !strcmp(argv[0], &DAT_8402e980model_string)) {
    /* DAT_8402e980model_string is the unit's own model string, in the same BSS block as
       the MRD copy -- so `ATSEget-seed EX3301-T0` on an EX3301, and nothing else.   */

    nonce = FUN_83fb94b0rand(0);            /* random/timer source               */
    if (!nonce) nonce = FUN_83fb94b0rand(0);/* retried once if it returns zero,  */
                                        /* because 0 is the "not run" state  */
    nonce &= 0xffffff;                  /* 24 bits -> 6 hex chars in S       */

    _DAT_8402e958saved_nonce = nonce;              /* stored where ATENunlock can find it.    */
                                        /* Non-zero doubles as the flag      */
                                        /* meaning "ATSEget-seed has been run".      */
                                        /* NOTE: 0x8402e958 == mrd+4, which  */
                                        /* is exactly where the builder      */
                                        /* reads the nonce back from.        */

    FUN_83fcc23cbuild_source(&DAT_8402e954mrd_struct, buf);   /* MRD + nonce -> S     (stage 1)    */
    FUN_83fd3da0encode_seed(buf, out);             /* S -> 36 chars        (stage 2)    */
    printf("%s", out);
}
  1. Gate on the model name. The argument must equal the unit's own model string at DAT_8402e980model_string. (ATDCskip-model-check — "disable check model mechanism" — exists to bypass exactly this.)

  2. Draw a nonce, 24-bit, stored at _DAT_8402e958saved_nonce. The listing calls FUN_83fb94b0rand a random/timer source, and the seeds settle which — four consecutive ATSEget-seed calls on one unit produced

    0A5D3E = 679230
    0B30D8 = 733400   +54170
    0B3984 = 735620    +2220
    0B423A = 737850    +2230
    

    strictly increasing, and clustered inside 0.35% of the 24-bit space. That is a free-running counter or timer, not rand(). It still makes each call unrepeatable, which is all the protocol needs, but "nonce" is doing flattering work: the value is predictable in principle. It hardly matters here — anyone close enough to type ATSEget-seed has the console already — but it is not the unpredictable challenge it looks like.

  3. Build S from the MRD struct — stage 1, below.

  4. Encode and print — stage 2, below.

ATENunlock 1,<password> — FUN_83fcc4d8cmd_aten

if (_DAT_8402e958saved_nonce == 0) { "please run atse first"; return -1; }
    /* the nonce is the gate: no ATSEget-seed this power cycle, no ATENunlock              */

if (strlen(pw) < 0x1f) {                /* 31 -- matches the transform's own */
                                        /* output buffer size                */

    FUN_83fcc23cbuild_source(&DAT_8402e954mrd_struct, buf);   /* rebuild S from the SAME MRD and   */
                                        /* the SAME still-live nonce, so it  */
                                        /* is byte-identical to what ATSEget-seed    */
                                        /* encoded  (stage 1)                */

    FUN_83fd408cderive_password(buf, expected);        /* S -> expected password (stage 3)  */

    if (!strncmp(pw, expected, strlen(expected))) {
        /* length taken from `expected`, not from `pw` -- a longer input      */
        /* whose prefix matches is still accepted                            */

        DAT_8402e9bedebug_flag = atoi(arg0);      /* the Boot Module Debug Flag.       */
                                        /* arg0 is the `1` in `ATENunlock 1,<pw>`  */
        return 0;
    } else "password is incorrect";
}
  1. Gate on the nonce being live, then on strlen(pw) < 0x1f.
  2. Rebuild S — same MRD, same nonce still in RAM, so byte-identical.
  3. Transform — stage 3, below.
  4. Compare and set the Boot Module Debug Flag.

The Password Derivation

That is the whole interface: one command prints, the other checks. Everything that matters happens in the three functions they share.

The Pivot

S is never shown and never typed. Both commands are just different views of it:

attacker inverts theinterleaveMRD struct @ 0x8402e954MAC · serial · 3 bytesFUN_83fcc23cbuild source stringnonce @ 0x8402e95824-bit, redrawn per ATSES — 0x2A bytes, ASCIIFUN_83fd3da03-way interleaveFUN_83fd408crotate + XOR36 chars — printed byATSEpassword — what ATENcomparesBoot Module Debug FlagDAT_8402e9be
attacker inverts theinterleaveMRD struct @ 0x8402e954MAC · serial · 3 bytesbuild_sourcebuild source stringnonce @ 0x8402e95824-bit, redrawn perget-seedS — 0x2A bytes, ASCIIencode_seed3-way interleavederive_passwordrotate + XOR36 chars — printed byget-seedpassword — what unlockcomparesBoot Module Debug Flagdebug_flag

The dotted edge is the finding: the printed seed leads back to S, and S leads to the password.

So the whole attack is one sentence: recover S from the view you are shown, then produce the view it wants. That is Putting It Together, below.

Two consequences worth noting now. The nonce makes this a genuine challenge–response rather than a static secret — the seed changes every call. And because the nonce lives only in RAM, ATENunlock works only while the same ATSEget-seed invocation is still current; that is what "please run atse first" is enforcing.


Stage 1 — Building The Source String S — FUN_83fcc23cbuild_source

Decompiled, the builder is a series of fixed-offset reads out of the MRD struct:

/* param_1 = &DAT_8402e954mrd_struct, the MRD struct in RAM
   param_2 = S, the 0x2B-byte output buffer                                  */

/* --- field 1: MAC, S[0x00..0x0b] ------------------------------------- */
uVar2 = (uint)*(byte *)(param_1 + 0x4d);      /* MRD holds the MAC as 6 raw */
/* ... uVar3..uVar6 read 0x4e..0x51 the same way ...   bytes at 0x4c..0x51  */
FUN_83fb87b8snprintf(param_2, 0xd, "%02X%02X%02X%02X%02X%02X",   /* snprintf        */
             *(undefined1 *)(param_1 + 0x4c), uVar2, uVar3, uVar4, uVar5, uVar6);
             /* size 0xd = 12 chars + NUL -> S[0x0c] is the terminator      */

/* --- field 2: serial-derived, S[0x0d..0x19] -------------------------- */
/* a bounded 13-byte copy from mrd+0x53 into S+0x0d that stops on the first */
/* NUL, so S[0x1a] is left as the terminator                                */

/* --- fields 3-5: three single bytes, 2 hex chars each ---------------- */
FUN_83fb87b8snprintf(param_2 + 0x1b, 3, "%02X", *(undefined1 *)(param_1 + 0x6a));
FUN_83fb87b8snprintf(param_2 + 0x1e, 3, "%02X", *(undefined1 *)(param_1 + 0x6b));
FUN_83fb87b8snprintf(param_2 + 0x21, 3, "%02X", *(undefined1 *)(param_1 + 0x69));
             /* note the MRD order is 6a, 6b, 69 -- not sequential          */

/* --- field 6: the nonce, S[0x24..0x29] ------------------------------- */
FUN_83fb87b8snprintf(param_2 + 0x24, 7, "%06X", *(undefined4 *)(param_1 + 4));
             /* param_1+4 IS 0x8402e958 -- the value ATSEget-seed just drew         */

Every field is written by its own snprintf with a size one larger than its content, so S is a run of NUL-terminated strings rather than a packed buffer. That is why the offsets are 0x00 / 0x0d / 0x1b / 0x1e / 0x21 / 0x24 with gaps: S[0x0c], S[0x1a], S[0x1d], S[0x20] and S[0x23] are terminators. Both consumers rely on it — they call strlen on each field.

Laid out, with the values from the EX3301-T0:

in S len from the MRD value encoded as
0x00 12 +0x4c, 6B — MAC "6C4F895553D0" "%02X" × 6
0x0d 13 +0x53, 13B — serial field "240952010890" copied as-is
0x1b 2 +0x6a, 1B "00" → x "%02X"
0x1e 2 +0x6b, 1B "00" → y "%02X"
0x21 2 +0x69, 1B "10" → z "%02X"
0x24 6 +0x04, 4B — nonce, redrawn every call "0A5D3E" → w "%06X"

len counts characters in S; the MRD column counts bytes. Every hex-encoded field therefore doubles — the MAC is 6 bytes in and 12 characters out — and the serial field is the only one copied verbatim. x, y and z are the names stage 3 gives those three bytes, where they combine as n = x + y + z; w is the nonce.

Two oddities worth noting: the three single bytes are read 6a, 6b, 69, not in address order, and +0x04 is mrd+4 — that is 0x8402e958, where ATSEget-seed had just stored the nonce.

The serial-derived field is the unit's serial with the leading 'S' dropped and 'Y' rendered as '9' — confirmed on all three units:

S240Y52010890  ->  240952010890     (EX3301-T0,    2023)
S240Y21020837  ->  240921020837     (VMG8623-T50B, 2023)
S190Y38131689  ->  190938131689     (VMG8825-T50,  2019)

The serial prefix tracks the build date — S240… on the 2023 units, S190… on the 2019 one — so the rule is not fitted to a single manufacturing era.


Stage 2 — What ATSEget-seed Prints — FUN_83fd3da0encode_seed

ATSEget-seed does not print S. It prints a 3-way interleave of it. From the decompilation, three loops write at stride 3 with offsets 1, 0 and 2:

FUN_83fb9fa8memset(param_2, 0, 0x25);       /* memset(out, 0, 37) = 36 chars + NUL */

/* ... the same 12/13/2/2/2/6 strlen guard as the transform ... */

FUN_83fb87b8snprintf(local_18nibble, 2, "%X", (int)*(char *)(param_1 + 0x11) % 0x10);
*(undefined1 *)(param_1 + 0x11) = local_18nibble[0];   /* normalise S[0x11] IN PLACE */

/* --- loop 1: MAC -> out[3k+1] ---------------------------------------- */
pcVar6 = param_2 + 1;                 /* first write slot                    */
iVar2  = 0;                           /* k = 0                               */
do {
  pcVar4 = (char *)(param_1 + iVar2); /* source = S[0x00 + k]                */
  /* ... 1-byte bounded copy of *pcVar4 to *pcVar6 ...                       */
  iVar2  = iVar2 + 1;
  pcVar6 = pcVar6 + 3;                /* stride 3                            */
} while (iVar2 != 0xc);               /* 12 iterations, k = 0..11            */

/* --- loop 2: serial field -> out[3k+0] ------------------------------- */
/* same shape as loop 1, 12 iterations, writing to param_2 + 3k and reading */
/* S[0x0d + iVar2] -- except that iVar2 starts at 1, not 0, so the first    */
/* byte copied is S[0x0e]                                                   */

/* --- loop 3: trailing fields -> out[3k+2] ---------------------------- */
pcVar6 = param_2 + 2;  pcVar3 = (char *)(param_1 + 0x1b);   /* out[2]  <- S[0x1b] */
pcVar6 = param_2 + 5;  pcVar3 = (char *)(param_1 + 0x1c);   /* out[5]  <- S[0x1c] */
/* ... unrolled, one pair per source byte, skipping the NUL terminators ...     */

Worked on the first verification vector. Read the printed seed in threes: the first character of each triple belongs to the serial field, the second to the MAC, the third to the trailing fields. Each row below sits directly under the characters it claims.

  seed   260│4C0│040│9F0│581│290│050│15A│055│83D│9D3│00E│
  serial 2  │4  │0  │9  │5  │2  │0  │1  │0  │8  │9  │0  │ out[3k+0] -> S[0x0e..0x19]
  MAC     6 │ C │ 4 │ F │ 8 │ 9 │ 5 │ 5 │ 5 │ 3 │ D │ 0 │ out[3k+1] -> S[0x00..0x0b]
  trail    0│  0│  0│  0│  1│  0│  0│  A│  5│  D│  3│  E│ out[3k+2] -> x, y, z, w

         x = 00   y = 00   z = 10   w = 0A5D3E

Only the six highlighted characters change. x, y and z come from fixed MRD bytes and the serial and MAC rows are the unit's own — so on a given box 30 of the 36 characters are identical on every ATSEget-seed call, and only w, the nonce, moves. Run ATSEget-seed twice and anything else shifting means you have decoded the wrong thing.

The decoded MAC is the unit's sticker MAC, which makes the inversion self-checking: if it does not match the sticker, the decode is wrong and the password derived from it will be too.

Three things fall out of this listing:

  • S[0x0d] is never emitted. Loop 2 starts its counter at 1, so the first serial character copied is S[0x0e]. The transform does not read S[0x0d] either, so nothing is lost — but it means the printed seed is not a complete picture of S, only of the parts that matter.
  • The normalisation writes back through param_1. ATSEget-seed mutates the source string before encoding it, so what you decode from the printed seed is already normalised. Putting It Together explains why redoing it silently corrupts the password.
  • Loop 3 is unrolled, one copy per byte, which is what makes the trailing layout explicit: 0x1b, 0x1c, 0x1e, 0x1f, 0x21, 0x22, 0x24..0x29 — the NUL terminators at 0x1d, 0x20 and 0x23 are stepped over.

Guarded by length assertions (12/13/2/2/2/6) which double as a layout spec.


Stage 3 — What ATENunlock Expects — FUN_83fd408cderive_password

The interesting half. Three 32-bit words are assembled by interleaving the serial field with the MAC, then rotated and XOR-masked:

/* param_1 = S (mutated in place by the normalisation below)
   param_2 = output buffer for the expected password                        */

local_60MAGIC[0] = 0x10f0a563;             /* the three magic constants          */
local_60MAGIC[1] = 0xbeafbeaf;             /* only [1] is exercised by our test  */
local_60MAGIC[2] = 0x14387052;             /* vectors -- see the caveat below    */

FUN_83fb9fa8memset(param_2, 0, 0x1f);       /* memset(out, 0, 31)                 */

/* --- guard: validate the layout of S -------------------------------- */
if (strlen(param_1)        == 0xc &&  /* MAC field       12 chars           */
    strlen(param_1 + 0x0d) == 0xd &&  /* serial field    13                 */
    strlen(param_1 + 0x1b) == 2   &&  /* byte fields      2 each            */
    strlen(param_1 + 0x1e) == 2   &&
    strlen(param_1 + 0x21) == 2   &&
    strlen(param_1 + 0x24) == 6) {    /* nonce            6                 */

  /* --- normalise S[0x11] ------------------------------------------- */
  FUN_83fb87b8snprintf(local_54nibble, 2, "%X", (int)param_1[0x11] % 0x10);
  param_1[0x11] = local_54nibble[0];        /* forces that one char into 0-9A-F   */

  /* --- build three 8-hex-digit strings by interleaving --------------- */
  /* each half is a 4-byte bounded copy; Ghidra emits six near-identical  */
  /* do/while loops, elided here:                                         */
  /*   local_50h1 = S[0x0e:0x12] + S[0x00:0x04]     serial | MAC             */
  /*   local_44h2 = S[0x12:0x16] + S[0x04:0x08]                              */
  /*   local_38h3 = S[0x16:0x1a] + S[0x08:0x0c]                              */

  uVar3 = FUN_83fb7dc8strtoul(local_50h1, 0, 0x10);   /* a = strtoul(h1, NULL, 16)   */
  uVar4 = FUN_83fb7dc8strtoul(local_44h2, 0, 0x10);   /* b                           */
  uVar5 = FUN_83fb7dc8strtoul(local_38h3, 0, 0x10);   /* c                           */

  iVar2  = FUN_83fb7dc8strtoul(pcVar12, 0, 0x10);   /* x = S[0x1b:0x1d]            */
  iVar10 = FUN_83fb7dc8strtoul(pcVar13, 0, 0x10);   /* y = S[0x1e:0x20]            */
  iVar6  = FUN_83fb7dc8strtoul(pcVar15, 0, 0x10);   /* z = S[0x21:0x23]            */
  uVar7  = FUN_83fb7dc8strtoul(pcVar16, 0, 0x10);   /* w = S[0x24:0x2a] -- nonce   */

  /* --- derive the schedule from the three bytes and the nonce -------- */
  uVar14 = iVar10 + iVar2 + iVar6;                     /* n = x + y + z     */
  uVar11 = (uVar7 & 0xffffff) + local_60MAGIC[uVar14 % 3];  /* mix: n picks the  */
                                                       /* magic constant    */
  uVar14 = uVar11 >> (uVar14 & 3) & 0xff;              /* rot: n also picks */
                                                       /* the shift, 0..3   */
  /* rot is one byte, so the loop runs at most 255 times                    */

  uVar7 = 0;                          /* <-- uVar7 is REUSED as the loop    */
                                      /* counter; w is already folded into  */
                                      /* mix and is not needed again        */
  if (uVar14 != 0) {
    do {
      uVar3 = (uVar3 >> 1) + uVar3 * -0x80000000;      /* ror32(a, 1)       */
      uVar4 = (uVar4 >> 2) + uVar4 *  0x40000000;      /* ror32(b, 2)       */
      uVar7 = uVar7 + 1;                               /* (interleaved by   */
      uVar5 = (uVar5 >> 3) + uVar5 *  0x20000000;      /*  the scheduler)   */
    } while (uVar7 != uVar14);        /* rot iterations                     */
  }

  /* --- mask and format ---------------------------------------------- */
  FUN_83fb87b8snprintf(param_2, 0x1f, "%u%u%u",       /* decimal, not hex           */
               uVar3 ^ uVar11, uVar4 ^ uVar11, uVar5 ^ uVar11);
}

That second interleave is a different one from stage 2 — four characters of the serial field followed by four of the MAC, three times over:

          S[0x0e:0x12]   S[0x00:0x04]
    a  =     "2409"    +    "6C4F"    =  0x24096C4F  ─┐
    b  =     "5201"    +    "8955"    =  0x52018955  ─┼─ ror 1 / 2 / 3,
    c  =     "0890"    +    "53D0"    =  0x089053D0  ─┘  rot times, then
          S[0x12:0x16]   S[0x04:0x08]                    XOR mix
          S[0x16:0x1a]   S[0x08:0x0c]

    n    = x + y + z      = 0x00 + 0x00 + 0x10   = 16
    mix  = w + MAGIC[n%3] = 0x0A5D3E + 0xBEAFBEAF = 0xBEBA1BED
    rot  = (mix >> (n&3)) & 0xff                  = 237

    -> 37037823101054363321514538314

Notes on reading this one:

  • uVar7 serves two roles. It holds the nonce w while mix is computed, then is zeroed and reused as the rotation counter. An excerpt that drops the uVar7 = 0 makes the loop look like it counts up from the nonce.
  • n is used twice, differently. n % 3 selects which magic constant goes into mix; n & 3 selects how far mix is shifted to produce rot. So the three small byte fields control both which constant is mixed in and how many rotations happen.
  • The rotations differ per word — 1, 2 and 3 bits — so a, b and c diverge as rot grows. All three are then XORed with the same mix.
  • The output is %u, not %x. Three 32-bit values printed as decimal and concatenated with no separator, which is why passwords are 29–30 digits and variable length. ATENunlock's own guard is strlen(pw) < 0x1f.

Reading tip: (v >> k) + v * (1 << (32-k)) is a rotate-right by k. Ghidra renders the shift-left half as a multiply by a signed constant — -0x80000000 is 1 << 31, 0x40000000 is 1 << 30, 0x20000000 is 1 << 29. Recognising this pattern is most of the work.

Faithful Python:

MAGIC = (0x10F0A563, 0xBEAFBEAF, 0x14387052)

def ror32(v, k):
    v &= 0xFFFFFFFF
    return ((v >> k) | (v << (32 - k))) & 0xFFFFFFFF

def transform(S, normalise=True):
    # normalise=False when S came from a printed ATSEget-seed seed -- see stage 2
    if normalise:
        S = list(S); S[0x11] = "%X" % (ord(S[0x11]) % 16); S = "".join(S)

    h1 = S[0x0E:0x12] + S[0x00:0x04]      # serial field interleaved with MAC
    h2 = S[0x12:0x16] + S[0x04:0x08]
    h3 = S[0x16:0x1A] + S[0x08:0x0C]
    a, b, c = int(h1, 16), int(h2, 16), int(h3, 16)

    x = int(S[0x1B:0x1D], 16); y = int(S[0x1E:0x20], 16); z = int(S[0x21:0x23], 16)
    w = int(S[0x24:0x2A], 16)

    n   = x + y + z
    mix = ((w & 0xFFFFFF) + MAGIC[n % 3]) & 0xFFFFFFFF
    rot = (mix >> (n & 3)) & 0xFF

    for _ in range(rot):
        a = ror32(a, 1); b = ror32(b, 2); c = ror32(c, 3)

    return "%u%u%u" % (a ^ mix, b ^ mix, c ^ mix)

The loader checks strlen(password) < 0x1f; real outputs are 29–30 digits.


Putting It Together — Seed To Password

The interleave of stage 2 is a pure permutation, so it inverts directly: read the 36 printed characters back into their S positions, then run the stage 3 transform over the result.

_TRAIL = [0x1B,0x1C, 0x1E,0x1F, 0x21,0x22, 0x24,0x25,0x26,0x27,0x28,0x29]

def seed_to_material(printed):
    S = bytearray(b"\0" * 0x2A)
    for k in range(12):
        S[0x0E + k]   = ord(printed[3*k + 0])
        S[0x00 + k]   = ord(printed[3*k + 1])
        S[_TRAIL[k]]  = ord(printed[3*k + 2])
    return S.decode("latin-1")

def password_from_seed(printed):
    return transform(seed_to_material(printed), normalise=False)

One subtlety worth calling out, because it is a silent-failure trap:

Both encoder and transform normalise S[0x11] from the raw MRD byte. A seed decoded from ATSEget-seed output is therefore already normalised — hence normalise=False above. Applying it twice produces a wrong password with no error, and the failure hides, because "%X" % (ord(c) % 16) is idempotent for '0'–'9'.

It is not idempotent for 'A'–'F' — 'A' becomes '1' — so a unit with a hex letter in that position would break a double-normalising implementation while every test vector here still passed. That is a narrower risk than it sounds. S[0x11] is the fourth character of the serial field, which is exactly where the literal Y sits in a serial like S190Y38131689, and the builder renders that Y as 9. Every serial seen — all three units, plus the S090Y00000000 fallback the firmware uses when the MRD is empty — follows the same S<YY>0Y<8 digits> shape, so that character is 9 on all of them.

Reaching the hex-letter path would take a unit whose serial breaks that format altogether. Worth guarding against if you reimplement this, but do not expect to trip it.


Verification

Three units, three models, three vendor OUIs — the Organizationally Unique Identifier, the first three bytes of a MAC address that the IEEE assigns to a manufacturer — and bootloader versions four years apart. The derivation was recovered from the EX3301's binary alone; the other two were unlocked with it unchanged.

EX3301-T0 VMG8623-T50B VMG8825-T50
bootbase V1.45 (02/2023) V1.49 (04/2023) V1.42 (02/2019)
serial S240Y52010890 S240Y21020837 S190Y38131689
MAC / OUI 6C:4F:89 80:EA:0B 98:0D:67
x/y/z 00 00 10 00 00 10 00 00 10
bootloader disassembled? yes no no

VMG8825-T50 is one of the models named in the community thread that reported the 36-character seed unsolved — the other two were not on that list.

One Unit Multiple Seeds

Four ATSEget-seed invocations on the EX3301-T0. ATENunlock accepted each, and all four reproduce offline:

printed seed nonce password
2604C00409F058129005015A05583D9D300E 0A5D3E 37037823101054363321514538314
2604C00409F058129005015B0538309DD008 0B30D8 55279548726161744651009020867
2604C00409F058129005015B0538399D8004 0B3984 246960043439585705184136833079
2604C00409F058129005015B0548329D300A 0B423A 256960214337066066972897312488

Only the highlighted characters change. Every seed decodes to the same MAC 6C4F895553D0 and the same serial field — the 30-of-36 constancy from stage 2 showing up in practice.

Six positions carry the nonce, but only five of them differ across these four: the first is 0 every time, because all four nonces happen to fall below 0x100000. The highlight marks where the nonce lives, not merely where these particular samples disagree.

Cross-Model, Cross-Version

Derived entirely from the EX3301's bootloader, run against hardware whose bootloader was never disassembled:

unit printed seed decoded MAC password
VMG8623-T50B 2804000E09A02011B00A028300F8C4364704 80EA0BA80C60 23938310265069687972771747575
VMG8825-T50 1909800009D03618701303E11D16F98AE900 980D673EDFA0 239356585215936564391135635649

Both took the flag to 01. In each case the decoded MAC matched what the unit's own ATSHshow-status had reported, before the password was sent — the free self-check from stage 2, doing its job on hardware it had never seen.

The whole thing on the VMG8825-T50, start to finish — identify, challenge, derive offline, answer, confirm, and the credentials that answering it exposes:

One take against a real unit, driven over the serial console. The seed and nonce differ from the vectors tabulated above because every ATSEget-seed draws a fresh one — that is the point of it.

Current Limitations

Two variables have not yet been checked on the hardware I have access to.

All three units decode x/y/z = 00 00 10, so n = 16 on all three. Therefore:

  • n % 3 == 1 → always MAGIC[1] = 0xBEAFBEAF
  • n & 3 == 0 → rot = (mix >> (n & 3)) & 0xff collapses to mix & 0xff

MAGIC[0] and MAGIC[2] have never executed on hardware. They are decompiler output, reproduced faithfully but unexercised, and the rotate count has never been shifted off zero.

That is not a run of bad luck across calls, it is structural. n is built from x + y + z alone — the nonce is not part of it, and only reaches mix after the constant has already been chosen. So the branch is fixed per unit: every one of the nine ATSEget-seed calls captured across the three units selected MAGIC[1] and a shift of 0, and no amount of re-running ATSEget-seed on this hardware could ever select anything else. Collecting more seeds from these boxes cannot move it; only different MRD bytes can.

With three different models this is beginning to look like a property of the devices rather than the sample. Those three MRD bytes appear to be fixed for the family. If this turns out to be true, the other two constants are not merely untested but unreachable on shipping hardware, and the algorithm reduces in practice to one constant and an unshifted rotate.

Establishing that properly needs a unit whose x/y/z differ. Any value of n other than 16 would do it: n = 15 or 17 exercises a new magic constant and a non-zero shift at once. That is not something I can settle with the hardware I have — if you own one of these, If It Works On Something I Have Not Tested above says what to send.


Affected Scope

Confirmed on hardware:

model bootbase in the community thread?
EX3301-T0 V1.45 (02/2023) no
VMG8623-T50B V1.49 (04/2023) no
VMG8825-T50 V1.42 (02/2019) yes

The thread that reported the 36-character seed unsolved named DX5401-B0, VMG8825-T50, WX5600 and EX5601. Two of the three units confirmed here were not on that list, so the affected family is wider than the models people happened to post about. DX5401-B0, WX5600 and EX5601 remain untested here. With the above evidence I would expect this method to work, however that is just speculation.

Any Zyxel device whose ATSEget-seed prints 36 hex characters is a candidate, and the qualification test is two commands that change nothing:

ZHAL> ATSHshow-status            # read the exact Product Model string
ZHAL> ATSEget-seed <MODEL>    # 36 hex characters -> this derivation applies

If ATSEget-seed <MODEL> prints a 12 character seed then the device is using the older scheme which has already been reversed. The "product model" printed by ATSHshow-status is the best way to find the model string.

Worth stating bluntly: this is not remote code execution. This is a local unlock that requires physical access to the UART console. Equivalent capability was already known through ATRTwrite-ram trick. What this method adds is that it should work without knowing anything about the particular build the device is running. While it does require access to the UART console, Zyxel were nice enough to leave soldered headers on all of the devices I tested.

There is also only one binary behind all of this. All three units unlock, but only the EX3301-T0's V1.45 bootloader was ever disassembled, so the tested window is V1.42 to V1.49. Anything outside that is an assumption rather than a finding. A different build could change the layout of S and nothing here would warn you except the MAC cross-check failing.

If It Works On Something I Have Not Tested

A sample size of three is still not very big. If you have any Zyxel hardware that I haven't tested and can run the unlock on please let me know the results. Even if the device prints the older 12 character seed, this is useful because it shows a device that this does not effect.

Four things are worth sending:

model string      ATSHshow-status -> Product Model
bootbase version  ATSHshow-status -> Bootbase Version
seed length       36 characters, or 12, or nothing
x / y / z         from the decoder, e.g. "00 00 10"

The x/y/z line is the valuable one. Every unit so far reads 00 00 10, which is why MAGIC[0] and MAGIC[2] have never executed — see Current Limitations. A unit reading anything else would run code that no hardware has yet run, and would settle whether those bytes are fixed for the family or merely happened to match on the devices I have access to.

It is also the safe thing to send. A full seed encodes the unit's MAC and serial number, so it identifies the device and, in a domestic setting, its owner. x/y/z is three bytes that say nothing about who or where you are. Send that line, not the seed. The online decoder will print it for you.

Anything useful goes to bruno@ministryoftinkering.co.uk, and I will add confirmed models to the table above.


Credits And Sources

The 36-character derivation is the only new part of this. Everything around it was already public, and the Prior Work table above records who established what; this is the reading list.

  • Zyxel Community — DX5401 ATENunlock/ATSEget-seed — where the 36-character seed was reported and left unsolved in 2023. The models named in that thread are the starting list in Affected Scope.
  • watchfulip — the ATRTwrite-ram RAM-write to the debug flag, ATCKdump-creds, and the EX3301/DX3301 work this builds on. The build-independence argument here is a comparison against that method, not a criticism of it: it got there first, by a different route.
  • hack-gpon — ATCKdump-creds on the EX5601-T0.
  • drumpinicho/ex3301pass — MRD credential extraction, which needs a shell first.
  • Kitz forums — the 12-character seed algorithm, public for years. It is the reason the 36-character variant stood out as unsolved rather than unexamined.
  • Ghidra — every listing in this post. The FUN_ and DAT_ names are its placeholders for symbols the binary does not carry; see Function Names.
    © MINISTRY OF TINKERING — ALL CONTENT UNCLASSIFIED UNLESS MARKED OTHERWISE  ///  ● ONLINE  ///  MoT-WEB-2026  ///  NO TAXPAYER FUNDS WERE USED  ///  RSS FEED AVAILABLE FOR FIELD OPERATIVES  ///  REPORT HARDWARE FAULTS TO THE APPROPRIATE DEPARTMENT  ///  ALL UNATTENDED E-WASTE WILL BE DISASSEMBLED  ///      © MINISTRY OF TINKERING — ALL CONTENT UNCLASSIFIED UNLESS MARKED OTHERWISE  ///  ● ONLINE  ///  MoT-WEB-2026  ///  NO TAXPAYER FUNDS WERE USED  ///  RSS FEED AVAILABLE FOR FIELD OPERATIVES  ///  REPORT HARDWARE FAULTS TO THE APPROPRIATE DEPARTMENT  ///  ALL UNATTENDED E-WASTE WILL BE DISASSEMBLED  ///