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 post | Descriptive | What it is |
|---|---|---|
| Command handlers | ||
FUN_83fcc3dc | cmd_atse | Handles ATSE — checks the model, draws the nonce, prints the seed |
FUN_83fcc4d8 | cmd_aten | Handles ATEN — re-derives the password and sets the flag |
| The three workers they share | ||
FUN_83fcc23c | build_source | MRD struct + nonce → the source string S (stage 1) |
FUN_83fd3da0 | encode_seed | S → the 36 characters ATSE prints (stage 2) |
FUN_83fd408c | derive_password | S → the password ATEN expects (stage 3) |
| libc, identified by their call signatures | ||
FUN_83fb87b8 | snprintf | Every field of S is written by one of these |
FUN_83fb7dc8 | strtoul | Hex parse, base 16 |
FUN_83fb9d4c | strlen | Both consumers call it on each field of S |
FUN_83fb9fa8 | memset | Zeroes the 37-byte output buffer |
FUN_83fb94b0 | rand | The nonce source |
| Data | ||
DAT_8402e954 | mrd_struct | The MRD copy in RAM — MAC, serial, the three small bytes |
_DAT_8402e958 | saved_nonce | mrd+4. Non-zero doubles as "ATSE has been run" |
DAT_8402e980 | model_string | The unit's own model string, in the same BSS block |
DAT_8402e9be | debug_flag | The Boot Module Debug Flag itself |
| Stack locals — the number is a frame offset | ||
local_60 | MAGIC | The three magic constants, written in as immediates at function entry rather than kept in .rodata. n % 3 picks one of them. |
local_50 | h1 | S[0x0e:0x12] + S[0x00:0x04] — four serial characters then four MAC, 8 hex digits. strtoul'd into a. |
local_44 | h2 | S[0x12:0x16] + S[0x04:0x08], the same shape one group along. Becomes b. |
local_38 | h3 | S[0x16:0x1a] + S[0x08:0x0c]. Becomes c. Declared 16 bytes rather than 12 only because it is last and absorbs the frame padding. |
local_54 | nibble | 2-byte scratch in *stage 3*: "%X" of the normalised S[0x11], copied straight back over it. |
local_18 | nibble | The 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 | ||
ATSE | get-seed | Prints the 36-character seed |
ATEN | unlock | ATEN 1,<password> sets the debug flag; ATEN 0 clears it |
ATCK | dump-creds | Prints supervisor, admin and Wi-Fi PSK in cleartext |
ATSH | show-status | Shows the flag, and the model string ATSE wants |
ATDC | skip-model-check | Disables the model-name gate on ATSE |
ATRT | write-ram | The 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);
}
-
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.) -
Draw a nonce, 24-bit, stored at
_DAT_8402e958saved_nonce. The listing callsFUN_83fb94b0randa random/timer source, and the seeds settle which — four consecutiveATSEget-seedcalls on one unit produced0A5D3E = 679230 0B30D8 = 733400 +54170 0B3984 = 735620 +2220 0B423A = 737850 +2230strictly 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 typeATSEget-seedhas the console already — but it is not the unpredictable challenge it looks like. -
Build
Sfrom the MRD struct — stage 1, below. -
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";
}
- Gate on the nonce being live, then on
strlen(pw) < 0x1f. - Rebuild
S— same MRD, same nonce still in RAM, so byte-identical. - Transform — stage 3, below.
- 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:
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 at1, so the first serial character copied isS[0x0e]. The transform does not readS[0x0d]either, so nothing is lost — but it means the printed seed is not a complete picture ofS, only of the parts that matter.- The normalisation writes back through
param_1.ATSEget-seedmutates 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 at0x1d,0x20and0x23are 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:
uVar7serves two roles. It holds the noncewwhilemixis computed, then is zeroed and reused as the rotation counter. An excerpt that drops theuVar7 = 0makes the loop look like it counts up from the nonce.nis used twice, differently.n % 3selects which magic constant goes intomix;n & 3selects how farmixis shifted to producerot. 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,bandcdiverge asrotgrows. All three are then XORed with the samemix. - 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 isstrlen(pw) < 0x1f.
Reading tip:
(v >> k) + v * (1 << (32-k))is a rotate-right byk. Ghidra renders the shift-left half as a multiply by a signed constant —-0x80000000is1 << 31,0x40000000is1 << 30,0x20000000is1 << 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:
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→ alwaysMAGIC[1] = 0xBEAFBEAFn & 3 == 0→rot = (mix >> (n & 3)) & 0xffcollapses tomix & 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-ramRAM-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-credson 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_andDAT_names are its placeholders for symbols the binary does not carry; see Function Names.