← Blog SEC1.DK
Reverse Engineering

Reverse Engineering the ZD1211 Finder Firmware Update Protocol

Or: how to flash an Allnet ALL0298 from Linux without the Windows updater

Author Niel Nielsen Date Jun. 2026
Reverse Engineering USB ZyDAS · 8051 WiFi Hardware Linux

Background

The Allnet ALL0298 is a USB WiFi hotspot finder from around 2005. Plug it in, it scans for open networks and shows signal strength on a small LED bar — standalone operation, no host needed. The hardware is a ZyDAS ZD1211 chipset (USB ID 157E:3204), which is the same silicon as the ZyXEL AG-225H and the U-Media WUB-410Z, all manufactured by U-Media Communications (MAC prefix 00:11:E0, FCC ID SI5WUB410).

The ZyXEL variant ships with a Windows firmware updater (ZdE2P.exe) that upgrades the finder application firmware stored in the ZD1211's internal flash. This firmware controls the scan logic, display modes, and features like passive scanning and hidden SSID detection. The Allnet unit I have was running 1.0.2.56 — newer than anything in the ZyXEL package (1.0.2.53 is the latest there). But that's getting ahead of the story.

The goal: reverse engineer the update protocol and flash from Linux.


Hardware Stack

The ZD1211 contains an embedded 8051 microcontroller that runs the finder application firmware. This is separate from the radio firmware (zd1211_ub, zd1211_uphr) that the Linux zd1211rw kernel driver uploads to RAM on every plug-in. The finder firmware lives in internal flash and persists across power cycles.

The USB interface exposes four endpoints:

In firmware update mode (hold Seek button, plug in USB, release when display shows "firmware upgrade mode"), the device enumerates with the same VID/PID (157E:3204) and the same descriptor. No re-enumeration. The Windows updater's ZDE2P.INI only lists 0ACE:A211 and 0586:3409 (ZyXEL and ZyDAS reference), which is why it refuses to run against the Allnet unit even on Windows.


Approach

With no Windows machine running old enough drivers, and Wine not handling the NDIS kernel driver stack, the only option was to reverse engineer the protocol directly.

The update package contains:

The actual USB protocol is buried four layers deep: ZD11PWL.dllZDPN50.dllZDPNDIS5.sysZD11UXP.sys (the miniport, from the driver package), all talking through NDIS OID calls that eventually become USB transfers.

Static analysis with Capstone disassembly of ZD11UXP.sys (360KB, x86 PE) found the relevant OID constants (0x4001000b, 0x4001000c, 0x40010012) and a state machine counter at a fixed adapter object offset — but the OID-to-USB mapping was dispatched through a vtable, making static tracing impractical. The more productive approach: probe the device directly with pyusb and usbmon.


Protocol Discovery

// Step 1: Tame the kernel driver

First problem: zd1211rw reloads automatically after every power cycle via udev, killing transfers in progress. Fix it first:

echo "blacklist zd1211rw" | sudo tee /etc/modprobe.d/zd1211rw-blacklist.conf
sudo modprobe -r zd1211rw

// Step 2: What does the device accept?

Systematic probing revealed:

Scanning all 256 single-byte commands on EP1 found that 0x60 was accepted — consistent with it being a mode-select byte ("re-update mode, do not erase block", per the bootloader changelog).

// Step 3: The 1024-byte boundary

Sending the firmware as a raw bulk stream revealed a hard limit: exactly 1024 bytes accepted every time, regardless of chunk size or inter-chunk delay. The endpoint wasn't stalled (GET_STATUS always returned 0x0000) — it was simply NAKing all subsequent packets.

usbmon confirmed:

S Bo:1:047:1 -115 1024 = 00f066fe ...
C Bo:1:047:1  0  1024 >               ← sector 1 OK, instant
S Bo:1:047:1 -115 1024 = 0f9f0afa ...
C Bo:1:047:1 -2  0                    ← sector 2: ENOENT after 10s timeout

The device receives 1024 bytes, buffers them, then waits for something from the host before accepting more.

// Step 4: Finding the unlock

A brute-force scan: send sector 1, then try every bRequest value (0x00–0xFF) as a vendor OUT control transfer, then immediately attempt sector 2:

SUCCESS with bRequest=0x32!

0x32 is USB_REQ_FIRMWARE_READ_DATA from the zd1211rw kernel driver header (zd_usb.h). But 0x32 alone wasn't enough. Further testing found the working sequence requires two control transfers between sectors:

Working inter-sector sequence:
ctrl OUT 0x40 0x31 → then → ctrl OUT 0x40 0x32

0x31 is USB_REQ_FIRMWARE_CONFIRM. It triggers the flash write cycle. 0x32 follows immediately and times out (the device is busy writing flash) — but that timeout is expected and normal. Once the flash write completes, the next sector write succeeds.


The Complete Protocol

for each 1024-byte sector:
    bulk OUT EP1:   sector data (1024 bytes, pad last with 0xFF)
    ctrl OUT 0x40 0x31 wValue=0 wIndex=0 wLength=0   ← trigger flash write
    ctrl OUT 0x40 0x32 wValue=0 wIndex=0 wLength=0   ← wait (will timeout, that's OK)

after final sector:
    ctrl IN  0xC0 0x31                                ← returns 0x01 = received OK

The Flasher

#!/usr/bin/env python3
"""
ZD1211 finder firmware flasher
Supports: Allnet ALL0298 (157E:3204), ZyXEL AG-225H (0586:3409), ZyDAS ref (0ACE:A211)
Device must be in update mode: hold Seek, plug in, release when display shows update mode.
Usage: sudo python3 zd1211_flash.py <firmware.bin>
"""
import sys, time
import usb.core, usb.util, usb.backend.libusb1

KNOWN_DEVICES = [(0x157E, 0x3204), (0x0586, 0x3409), (0x0ACE, 0xA211)]
EP_BULK_OUT = 0x01
SECTOR_SIZE = 1024

def get_backend():
    return usb.backend.libusb1.get_backend()

def find_device(backend):
    for vid, pid in KNOWN_DEVICES:
        dev = usb.core.find(idVendor=vid, idProduct=pid, backend=backend)
        if dev:
            print(f"Found: {vid:04X}:{pid:04X}")
            return dev
    return None

def ctrl(dev, bmrt, req, wval=0, widx=0, data=b'', timeout=2000):
    try:
        if bmrt & 0x80:
            return bytes(dev.ctrl_transfer(bmrt, req, wval, widx, 4, timeout=timeout))
        else:
            dev.ctrl_transfer(bmrt, req, wval, widx, data, timeout=timeout)
    except:
        pass

def main():
    fw = open(sys.argv[1], 'rb').read()
    if len(fw) % SECTOR_SIZE:
        fw += b'\xff' * (SECTOR_SIZE - len(fw) % SECTOR_SIZE)
    n = len(fw) // SECTOR_SIZE
    print(f"Firmware: {len(fw)} bytes ({n} sectors)")

    backend = get_backend()
    dev = find_device(backend)
    if not dev:
        print("Device not found. Enter update mode first.")
        sys.exit(1)

    try:
        for iface in range(dev[0].bNumInterfaces):
            if dev.is_kernel_driver_active(iface):
                dev.detach_kernel_driver(iface)
    except:
        pass
    dev.set_configuration()

    print("Flashing — do NOT disconnect USB...")
    for i in range(n):
        offset = i * SECTOR_SIZE
        print(f"\r  Sector {i+1}/{n} offset={offset:#06x}", end='', flush=True)
        dev.write(EP_BULK_OUT, bytes(fw[offset:offset+SECTOR_SIZE]), timeout=10000)
        if i < n - 1:
            ctrl(dev, 0x40, 0x31)  # trigger flash write
            ctrl(dev, 0x40, 0x32)  # wait for completion (expected timeout)

    r = ctrl(dev, 0xC0, 0x31, timeout=5000)
    if r:
        print(f"\nFinal status: {r.hex()}")
    print("Done. Unplug and replug.")

if __name__ == '__main__':
    main()

The Twist

After successfully flashing all 31 sectors with the correct protocol, the version shown on the display remained 1.0.2.56 — unchanged after flashing both v10253 and v10151.

Reason: downgrade protection.
The bootloader checks the version number in the incoming firmware and silently discards anything older than what's currently installed. The 0x31 IN returning 0x01 means "data received OK" — not "data written to flash". The display shows "waiting" throughout and never transitions to "programming" because the version check fails silently.

The Allnet unit was already running 1.0.2.56, newer than anything in the ZyXEL package. The protocol works correctly — it just won't let you go backwards.


Summary

DeviceAllnet ALL0298 v2 / ZyXEL AG-225H / U-Media WUB-410Z
ChipZyDAS ZD1211 (8051 embedded core)
Update modeHold Seek during USB plug-in, release on display prompt
Sector size1024 bytes
Inter-sector handshakectrl OUT 0x31 then ctrl OUT 0x32
Final confirmctrl IN 0x310x01 = received OK
Downgrade protectionYes — bootloader silently rejects older firmware
Linux toolpyusb + libusb

The flasher and all probe scripts are available on GitHub.