Apache Superset, Gamma v0 import plants dataset SQL without sql_lab
Apache Superset 6.1.0 default Gamma posts unversioned dashboard JSON and overwrites a granted dataset's SQL. Chart/data then runs that query on the analytics connection. No sql_lab. No Dataset write. A dashboard editor can hide a query in a trusted chart. No CVE yet.
- Name
- Apache Superset, Gamma v0 import plants dataset SQL without sql_lab
- Type
- 0-day analysis
- CVE
- n/a
- CVE Risk
- high
- Disclosure Status
- public
- Vendor
- Apache Software Foundation
- Affected
- Apache Superset 6.1.0 (c83fb2bb). Default Gamma with Dashboard write. Name-matched granted dataset. Leftover of CVE-2026-23982 / CVE-2024-26016.
- Published
- 20 Oct 2026
- Updated
- 20 Oct 2026
- Tags
- 0-day, apache-superset, privilege-leftover, import, cwe-863, cwe-284, authenticated
The extra SQL permission was the story. v0 import did not read it.
I am @abraxas_null. The proof of concept is on GitHub: abraxas/superset-g3-v0-import-sql-plant (loopback client). The lab stack is lab/: Dockerfile, docker-compose.yml, run.sh, poc.py. Authorized lab only. It talks to loopback.
A coworker with a normal dashboard-editor account can hide their own database query inside a chart other people already trust. The next time anyone opens that chart, the query runs against the company's data connection. They can read tables they were never given access to. They do not need the extra "run my own SQL" permission that is supposed to gate this. They cannot register a new warehouse login, and they cannot run INSERT or DELETE through this chart path.
This is Apache Superset 6.1.0 (c83fb2bb1dcfac41ac51bcebd82471f4a7180d18). No CVE yet. CWE-863 / CWE-284. 7.6 High (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L). Reach: Remote. Class: Privilege leftover. Authenticated default Gamma. Leftover of CVE-2026-23982 (Dataset REST SQL) and CVE-2024-26016 (versioned ZIP import authz).
Vendor intake is security@superset.apache.org. Unpaid ASF. I am not opening a public issue on apache/superset.
Why Superset
Superset is the BI box a lot of companies put in front of the warehouse. The security story on paper is clean. Admin is the OS. Gamma charts what you grant. SQL Lab is the extra role for people who are allowed to type SELECT. Dataset write is the extra role for people who are allowed to change what a chart even means.
That matrix only works if every write of SqlaTable.sql goes through Dataset write, and every execute of attacker SQL goes through SQL Lab. I wanted to know whether 6.1.0 still had a Gamma door that wrote SQL without either.
Pinning 6.1.0
Latest supported GA on GitHub at hunt: tag 6.1.0, commit c83fb2bb, 2026-05-01. Image apache/superset:6.1.0. 7.0.0 was still RC (7.0.0rc2). I pin GA.
SECURITY.md lives on master, not on the 6.1.0 tag. Canonical model: Admin is OS-equivalent and out of scope. In-scope principals are Public, embedded guest, Gamma, Alpha, and additive sql_lab. The test is whether a principal can do something the role matrix does not entitle them to.
The skip list is half the hunt
6.1.0 already ships the 6.0.0 wave:
- CVE-2026-23980 chart/data
sqlExpression/ where. - CVE-2026-23982 Dataset create/overwrite SQL on the REST path.
- CVE-2026-23984 SQL Lab PostgreSQL read-only DML (
DO $$,EXPLAIN ANALYZE). - CVE-2026-23981 chart-update dashboard assoc.
- CVE-2026-23983 tags associated-objects leak.
Older class I did not re-file: CVE-2024-26016 versioned dashboard/chart ZIP import, CVE-2023-39265 sqlite URI on Database create, CVE-2023-27524 default SECRET_KEY (6.1.0 refuses to start on CHANGE_ME), pickle on the HTTP key-value path (JsonKeyValueCodec), FAB Role/User APIs with FAB_ADD_SECURITY_API.
Those sinks are closed on this pin. I was looking for siblings.
Thirty surfaces, one leftover I labbed
I split the tree. Auth/session, guest tokens, Public role, SQL Lab DML, dataset overwrite, sqlExpression, Jinja, RLS, import/pickle, database URI, reports webhooks, screenshots, MCP, extensions, chart/dashboard IDOR, FAB security API, CSV/file read, XSS/CSS, websocket, version history, SQL execution library, feature flags, virtual datasets/explore, cache/key-value, Gamma-to-SQL chain, engine denylist, tags/permalinks, SSH/themes, REST sweep, docker/celery.
Public default came back empty (PUBLIC_ROLE_LIKE off). Guest extras.where is the same guarded sink as CVE-2026-23980. Chart/data sanitize_clause holds. SQL Lab execute as Gamma is 403. Dataset REST PUT as Gamma is 403.
The leftover is unversioned dashboard import.
v1 asks for metadata.yaml. v0 does not.
POST /api/v1/dashboard/import/ is Dashboard write. Default Gamma has can_write on Dashboard. The OpenAPI says ZIP or JSON. ZIP goes through get_contents_from_bundle. Anything else is {filename: bytes}.
The dispatcher is honest about why v0 still exists:
# list of different import formats supported; v0 should be last because
# the files are not versioned
command_versions = [
v1.ImportDashboardsCommand,
v0.ImportDashboardsCommand,
]v1 load_metadata raises IncorrectVersionError("Missing metadata.yaml") when the file is not there. The dispatcher catches that and tries the next command. Next is v0.
v0 decode_dashboards is an object_hook. __SqlaTable__ becomes a live SqlaTable. __Dashboard__ becomes a Dashboard. Then import_dashboards walks data["datasources"] and calls import_dataset.
import_dataset looks up the Database by params.database_name. Missing name is DatabaseNotFoundError. Then it looks up the table by table_name + schema + database_id. If a row exists:
if datasource:
datasource.override(i_datasource)
db.session.flush()override copies every name in export_fields. SqlaTable.export_fields includes sql. There is no raise_for_access. There is no Dataset write check. Gamma is not entitled to write datasets. This path does not ask.
Query time trusts stored SQL
Once sql is on the row, the dataset is a virtual table. get_from_clause renders that SQL, refuses has_mutation(), best-effort applies RLS, and wraps it as TextAsFrom(...).alias("virtual_table"). It does not parse table names out of the subquery and ask raise_for_access for each one.
POST /api/v1/chart/data goes through ChartDataCommand.validate into raise_for_access(query_context=). That check is the dataset object: schema access, datasource_access, owner, or dashboard-scoped guest/RBAC. It is not the tables named inside stored SQL.
That is intended once writing sql is a Dataset-write capability. The leftover is who can write that SQL.
Gamma with datasource_access on the overwritten dataset then gets a 200 and a compiled FROM (SELECT ...) AS virtual_table. SQL Lab stays 403. The extra permission was never consulted.
What an attacker can do
Needs a Gamma (or any Dashboard-write) account and a dataset they can later chart: same table_name + schema on a Database whose database_name is in the JSON params. Complementary grant is on the dataset object, not every table the planted SQL names.
| Privilege | Method / sink | Recon / integrity / availability | Lab-proven or inferred |
|---|---|---|---|
| Overwrite virtual SQL of a name-matched dataset | Gamma POST /api/v1/dashboard/import/ non-ZIP JSON; v0 import_dataset datasource.override copies export_fields including sql |
Integrity (dataset.sql) | Lab-proven. Import 200; dataset.sql = SELECT 'SUPERSET-G3-V0-IMPORT-SQL-PLANT-WITNESS' AS witness |
| Run that SQL on the dataset's analytics URI | POST /api/v1/chart/data after plant; get_from_clause wraps stored sql as TextAsFrom |
Recon (SELECT any table that URI user can read) | Lab-proven. 200; compiled FROM (SELECT 'SUPERSET-G3-V0-IMPORT-SQL-PLANT-WITNESS' AS witness) AS virtual_table |
| Hide the query in a chart other granted users already open | Same plant; owners / other datasource_access principals execute on refresh |
Integrity + recon (other people run the plant) | Inferred. Lab Gamma already had the grant so self-executed |
| Break dashboards that used the old SQL | Replace physical-table sql with attacker SELECT | Availability | Inferred |
| Negative: SQL Lab execute | POST /api/v1/sqllab/execute/ |
n/a | Lab-proven. 403 |
| Negative: Dataset REST overwrite | PUT /api/v1/dataset/{id} with sql |
n/a | Lab-proven. 403; sql stayed empty (CVE-2026-23982 sibling) |
Negative: v1 ZIP with metadata.yaml |
same import endpoint, ZIP bundle | n/a | Lab-proven. 200; sql stayed empty (CVE-2024-26016 class) |
Closed on this leftover: unauthenticated import; registering a new Database URI (v0 dataset import does not create the connection; missing params.database_name is DatabaseNotFoundError); DML through chart/data (has_mutation()); Gamma charting a dataset they cannot read (403); a newly inserted unique table_name (PVM created, not attached to Gamma). Not RCE. Not Admin. Production warehouse split limits SELECT to that URI user. Lab analytics URI was the same Postgres as metadata, so cross-table including ab_user is inferred, not second-oracled.
Wrong turns, in the order I made them
Filing Dataset REST. CVE-2026-23982 closed PUT /api/v1/dataset/{id} SQL. Gamma gets 403. The sibling is v0 import override, not the REST serializer.
Filing v1 ZIP. CVE-2024-26016 class. Ownership-checked path. Lab POST of a ZIP with metadata.yaml returned 200 and left sql empty. The interesting file is the one without metadata.yaml.
SQL Lab as the execute half. Gamma cannot hit /api/v1/sqllab/execute/. The execute half is chart/data on a dataset the attacker can already chart. Stored SQL is trusted at FROM-compile.
Any Database object. v0 lookup needs an existing database_name. It does not call Database.import_from_dict. A sqlite URI plant is a different importer.
New unique table_name. Insert path creates the dataset and the datasource_access permission-view-menu. It does not attach that PVM to Gamma. Gamma cannot chart it. The useful plant is overwrite of a name they already have.
Overwrite of a dataset Gamma cannot read. Import can still write sql. Gamma then 403s on chart/data. Owners and other granted users execute on refresh. Lab oracled the granted-dataset case.
DML. get_from_clause raises if has_mutation(). This leftover is SELECT. Theatre to call it write-your-warehouse.
A reverse shell. Theatre. The witness is the virtual FROM.
The lab
Source of truth is lab/. Image apache/superset:6.1.0. Host bind 127.0.0.1:19710. Compose project superset-g3-v0-import. superset init, Admin, Gamma-only user, physical dataset lab_items on Lab Analytics, Gamma datasource_access. Oracle is a witness string.
Gamma can chart the physical table. SQL Lab execute is 403. Dataset PUT is 403. v1 ZIP is 200 and sql stays empty. v0 JSON is 200 and sql becomes the witness SELECT. chart/data compiles it as the virtual FROM and returns the witness.
git clone https://github.com/abraxas/superset-g3-v0-import-sql-plant
cd superset-g3-v0-import-sql-plant
python3 superset-g3-v0-import-sql-plant-Abraxas-Labs.pyLast lab run, trimmed:
SUCCESS superset-g3-v0-import-sql-plant gamma-import-ok plant-sql-ok chart-data-witness-ok sqllab-403 dataset-put-403 v1-zip-no-overwrite SUPERSET-G3-V0-IMPORT-SQL-PLANT-WITNESSI am not printing a dashboard JSON you can paste at someone else's /api/v1/dashboard/import/. The missing Dataset write check on v0 override is the useful part.
The fix
v0 import_dataset must require Dataset write and raise_for_access before override copies sql. Drop unversioned v0 from command_versions, or refuse imports that lack metadata.yaml. Keep treating stored virtual SQL as a Dataset-write capability at query time.
References
- Proof of concept: abraxas/superset-g3-v0-import-sql-plant · superset-g3-v0-import-sql-plant-Abraxas-Labs.py
- Lab:
lab/· docker-compose.yml · run.sh · poc.py - @abraxas_null · github.com/abraxas · abraxaslabs.tech · abraxas.null@proton.me
- CWE-863 · CWE-284
- Tree: apache/superset 6.1.0 (
c83fb2bb1dcfac41ac51bcebd82471f4a7180d18) ·dashboards/api.py·dispatcher.py·dashboard importers/v0.py·dataset importers/v0.py·helpers.py·sqla/models.py - Nearby (not this path): CVE-2026-23982 · CVE-2024-26016