Getting some of the way: patch-diffing CVE-2023-27992 (ZyXEL NAS Pre-Auth RCE).

I came across this advisory from ZyXEL, and it seemed a good candidate for an evenings patch diffing.

"The pre-authentication command injection vulnerability in some Zyxel NAS devices could allow an unauthenticated attacker to execute some operating system (OS) commands remotely by sending a crafted HTTP request."

Caveat: I never finished the work, I never built a working exploit yet, as I don't possess a device to work with and really can't justify buying one when I am still waiting for my ZyWALL to arrive... This post will, however, save you some time if you want to pick up where I left off.

Or maybe, I'm wrong about everything. But heres the process.

We start off our adventures by acquiring pre and post-patch firmware.

MD5 (NAS326_V5.21(AAZF.13)C0.zip) = ccb215476a361e0ee8cb57a0cc65d596
MD5 (NAS326_V5.21(AAZF.14)C0.zip) = bd898842660e8c1a71f701626da18c41

We unzip these to reveal some nice .bin files.

MD5 (521AAZF13C0.bin) = 3533ad031f81375399a0858edcc2d7be
MD5 (521AAZF14C0.bin) = ba1b7828ec63b73074cc5885fded6375

We simply unpacked these with binwalk, its what we had handy, and it gives us two filesystems for each - an ext-root containing some shit, and a cpio-root containing some other shit.

We use meld merge to diff the ext-root's, and noticed there sure is a bunch of changes in the Python webservices.

This looks promising.

This Python stuff looks good to me, so we package up each version, and get to decompiling with uncompyle6 (as its fucking .pyc files). Some of it doesn't decompile properly, but we do end up with this really interesting diff.

$ diff decomp_framework13/main_wsgi.py decomp_framework14/main_wsgi.py 
5,6c5,6
< # Embedded file name: /home/release-build/NAS326/521AAZF13B1/sysapps/web_framework/build/main_wsgi.py.pre
< # Compiled at: 2023-05-02 04:13:10
---
> # Embedded file name: /home/release-build/NAS326/521AAZF14B2/sysapps/web_framework/build/main_wsgi.py.pre
> # Compiled at: 2023-05-26 04:06:07
19a20
> import re
23a25,67
> 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
> 
> 
79,82c123,124
<         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
84c126,141
<             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
89,94c146,147
<         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
96,101c149,169
<             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

What appears to have been added here, mostly, is some input sanitizing. They specifically are checking allowed shit against a regex, and also very specifically blocking the use of the backtick character, which is a Big Hint towards shell command injection somewhere within this abomination of CherryPy.

This view may be better?

We can see the cherrypy exposed functions ck6fup6 and tjp6jp6y4 have now got new, exciting input validation. These seem to shit data off to some backend cherrypy functionality.

Most of the cherrypy backend code is garbage that calls system, subprocess, popen, and has names like "execRoot", which is below for your amusement.

def execRoot(cmd):
    exec_path = os.path.join(sub('/lib.*$', '', os.path.realpath(__file__)), 'bin/executer_su')
    arg_set = [exec_path] + cmd
    pipe = Popen(arg_set, stdout=PIPE, stderr=None)
    return (pipe.communicate()[0], pipe.returncode)


def execRoot_bg(cmd):
    exec_path = os.path.join(sub('/lib.*$', '', os.path.realpath(__file__)), 'bin/executer_su')
    os.system(exec_path + ' "' + string.join(cmd, '" "') + '" &')


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

Now, for finding endpoints that reach such code, we simply go to htdocs and grep for  ck6fup6 and tjp6jp6y4. I put one of the outputs below, the other was too large.

% grep -r 'tjp6jp6y4' .
./playzone,/script/video.js:			var videoUrl = 'http://'+location.hostname+'/cmd,/tjp6jp6y4/playlist_main/smil_video_list?authtok='+encodeToken+'&host_ip='+Ext.getCmp('home').prefixURL.slice(7)+itemIds+'&limit=5'+'&parentId='+videoRecord[0].data.parentId+'&playNext='+Ext.state.Manager.getProvider().readCookies().video_autoResume+'&startId='+videoRecord[0].data.id;
./playzone,/script/player.js:			url:'/cmd,/tjp6jp6y4/register_main/auth_ping'
./playzone,/script/photo.js:			feedUrl:'/cmd,/tjp6jp6y4/playlist_main/cooliris_photo_list?baseId=' + paramObj.baseId +
./playzone,/script/photo.js:			url: '/cmd,/tjp6jp6y4/playlist_main/mono_photo_list',

I'd suggest fuzzing these endpoints/requests from an unauthenticated perspective, might lead you to victory. Just ram in some backticks.

Also worth looking at is what is permitted to 'skip' auth in the httpd.conf files.

 % grep SkipPattern *
httpd.conf: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/
httpd_dav.conf:AuthZyxelSkipPattern /webdav
httpd_default.conf: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/

I shall leave you there. Good luck.