A database vault with tenant partition walls dissolving as one query key opens every drawer, dark neon data-grid aesthetic, 16:9

The Finding I Killed First: CVE-2026-55098 in arc

I audited arc's DuckDB sandbox, called the fix complete, and killed the target — then re-dived the next CVE and found the allowlist was scoped to the whole multi-tenant root, not the tenant. The finding I got wrong first. CVE-2026-55098.

CVE-2026-55098 · GHSA-93cm-2v4m-c56c · Basekick-Labs/arc · CWE-200/863 · Medium (CVSS 6.5)

The one I killed

This is the post I most wanted to write, because I got it wrong first.

arc is a time-series data platform. Back on 2026-06-09 I looked at an advisory on it (GHSA-p2j4-c4g6-rpf5) about reading arbitrary parquet files through the query API. The fix added a DuckDB sandbox: allowed_directories set, plus enable_external_access=false. I looked at that and wrote KILL in my notes. My reasoning was, honestly, good reasoning:

This is an allowlist, not a denylist. Whack-a-mole concern moot. The sandbox prevents filesystem escape. Complete fix.

And I was right — about escape. The sandbox does stop you from reading /etc/passwd. What I never asked was: what is the allowlist scoped to?

The re-dive

Weeks later a second CVE landed on the same target — CVE-2026-47735, another parquet-read issue — and the lead-watch queue surfaced it. My own target-profiler scored it GO (78/100). So I went back in, this time reading the sandbox config line by line instead of pattern-matching "allowlist = safe."

// internal/database/sandbox.go:111
// allowed_directories = cfg.LocalStorageRoot

LocalStorageRoot. Not the requesting tenant's directory. The entire multi-tenant storage root — every tenant's data, under one allowlisted path. The sandbox was doing exactly what I'd credited it for (no escape to the host filesystem) while completely failing at the thing that actually mattered here (keeping tenant A out of tenant B's parquet files).

The fix author knew this was the threat. It's written in a comment four lines above the guard:

// query.go:2061 — "a user scoped to db1.m1 could read /data/db2/secrets"

They documented the exact attack and then defended it with a denylist of the literal string read_parquet(.

Three gaps that compose

Once I stopped trusting the sandbox, the RBAC layer fell apart in three pieces:

  1. The sandbox allowlists the shared root, so any path under it is fair game.
  2. The RBAC table-extractor skips anything followed by ( — it treats something( as a function call, extracts zero table references, and the allow-on-zero-refs branch waves it through.
  3. The denylist matches one spelling. DuckDB documents parquet_scan( as a direct alias for read_parquet( — same function, different name, not on the list. And the rest of the I/O family (glob, read_blob, parquet_metadata, read_csv_auto, …) isn't either.

Put together, any authenticated principal — including a token with permissions: [] — can read any other tenant's data:

POST /api/v1/query
{"sql": "SELECT * FROM parquet_scan('./data/arc/<victim-db>/<measurement>/**/*.parquet')"}

The proof

Same rule as always: verify at the sink. I ran arc's exact sandbox config against the DuckDB version arc pins (1.5.x) and checked each function:

[BLOCKED ] read_blob OUTSIDE root         <- sandbox works, as I'd said
[EXECUTES] parquet_scan victim db          <- the read_parquet alias
[EXECUTES] glob enumerate every db          <- list all tenants
[EXECUTES] parquet_metadata victim          <- schema disclosure

The first line is the finding I killed in June, still holding. The next three are the finding I missed by asking the wrong question.

The lesson

A sandbox that prevents escape is not the same as a sandbox that enforces the trust boundary. When you see an allowlist and feel relief, that feeling is the bug asking you to stop reading. Check what the allowlist is scoped to, not just whether one exists. An allowlist pointed at the wrong directory is a denylist wearing a nicer coat.

The fix is to scope allowed_directories per-request to the calling token's authorized DB path — the boundary the RBAC layer already knows — or to replace the string denylist with an alias-resolved allowlist of table functions.

Disclosed via GitHub Security Advisory; published as GHSA-93cm-2v4m-c56c / CVE-2026-55098. My thanks to the arc maintainers for a fast, professional triage.

No comments yet