
Apache Spark 4.2.0 - High - Remote - File write - History appStorePath unsanitized App ID
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · spark-history-store-path
Class: File write (config-gated) Reach: Remote
Apache Spark 4.2.0 (32f72996) - Apache Software Foundation
HistoryServerDiskManager.appStorePath does new File(appStoreDir, appId + extension) with no sanitize. Utils.sanitizeDirName is only for event-log filenames. JSON App ID is asText(). Listing stores event.appId.orNull. Lease.commit uses that string. App ID ../ writes a .rdb outside spark.history.store.path. Default unset store.path is in-memory (no write). Contents are a store file, not RCE.
| ID | no CVE yet |
| Class | File write (config-gated History disk store) |
| Reach | Remote (event-log writer plus History with spark.history.store.path set; UI ACLs default off) |
| CWE | CWE-22 |
| CVSS | High: 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:N when spark.history.store.path is set |
| Product | Apache Spark History Server |
| Affected | 4.2.0 (32f7299601108917fb01920a54e084595b7b3bf8) |
| Auth | writer of spark.history.fs.logDirectory (default file:/tmp/spark-events); History must have spark.history.store.path set |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1:18990 only. Gadget of the cited join. Witness .rdb outside store.path. |
Same victim model as CVE-2025-54920: History Server pointed at a log directory the attacker can write. Jackson no longer constructs HiveConnection-class gadgets. The disk-store path still trusts the event-JSON App ID.
| Method | Sink | Privilege | Lab vs inferred |
|---|---|---|---|
Plant an event log whose JSON App ID is ../<name> (or ../../oracle/...). Wait for scan. GET that loads the app UI / getAppUI / loadDiskStore / createDiskStore | HistoryServerDiskManager.appStorePath then Lease.commit renameTo | Integrity: create a .rdb (default RocksDB) or .ldb (LevelDB) path outside {store.path}/apps as the History UID. Lab-proven on the gadget: escaped=yes, file /work/oracle/SPARK-HISTORY-STORE-PATH-WITNESS.rdb contains the witness. Contents are a store file, not attacker-chosen host bytecode, not a shell. | Lab-proven (gadget write of witness bytes at the escaped path). Full History renameTo of a RocksDB directory is the same join. |
| Same App ID when the escaped destination already is a directory | Lease.commit if (dst.isDirectory()) deleteStore(dst) then Utils.deleteRecursively | Availability: delete an existing directory whose name already ends in the backend suffix, then replace it with the leased store. | Inferred (pin HistoryServerDiskManager.scala commit). Gadget wrote a file, did not call deleteRecursively. |
Default unset spark.history.store.path | LOCAL_STORE_DIR createOptional; diskManager = storePath.map | Closed. In-memory listing. No HistoryServerDiskManager. Lab-proven gated=yes written=no. | Lab-proven |
Apply Utils.sanitizeDirName to the App ID first | event-log filename helper | Stays inside apps/. / and : become -. Lab-proven negative. | Lab-proven |
Control App ID local-control with store.path set | same join | Writes {store.path}/apps/local-control.rdb. Stays inside apps/. | Lab-proven |
Adjacent closed: default unset store (in-memory). Jackson sparkEventFromJson still requires SparkListenerEvent (CVE-2025-54920). URL getAppUI fallback eventlog_v2_ + nameForAppAndAttempt is sanitized. Listing KV key is not a filesystem path. HybridStore / kvstore mapper has no Id.CLASS. History HTTP does not accept an upload path. Compaction default off. Cleaner default off. Job submit on an open cluster (SECURITY.md FAQ). ML model load (docs/ml-security.md). This leftover is not host RCE.
I pinned Spark 4.2.0, not master, not a 4.3 RC. Twenty-eight hunts. The public History Jackson RCE is closed on this peel. Event-log writers already call Utils.sanitizeDirName so the log file cannot contain /. The disk-cache file name uses the raw App ID.
private[history] def appStorePath(appId: String, attemptId: Option[String]): File = {
val fileName = appId + attemptId.map("_" + _).getOrElse("") + extension
new File(appStoreDir, fileName)
}
appStoreDir is {spark.history.store.path}/apps. Unix new File(parent, "../../oracle/name.rdb") resolves outside parent. JsonProtocol.applicationStartFromJson takes json.get("App ID").asText(). AppListingListener.onApplicationStart stores event.appId.orNull. lease.commit(appId, attempt.info.attemptId) feeds that string to appStorePath.
Wrong turns already recorded: treating default unset store.path as a write (it is in-memory); expecting Unix new File(dir, "/abs") to ignore the parent (it does not; leftover is ..); claiming RCE because the suffix is .rdb; booting a full History Server when the join is the oracle; treating sanitizeDirName as covering this path.
python3 spark-history-store-path-Abraxas-Labs.py
or
cd lab
./run.sh
Gadget of HistoryServerDiskManager.appStorePath on 127.0.0.1:18990. Pin excerpts under lab/pin/. Full History Server is not booted. Bind it to loopback.
SUCCESS SPARK-HISTORY-STORE-PATH how=gadget escaped=yes gated=yes outside=/work/oracle/SPARK-HISTORY-STORE-PATH-WITNESS.rdb SPARK-HISTORY-STORE-PATH-WITNESS
Run Utils.sanitizeDirName (or reject .. / / / NUL) on appId and attemptId inside appStorePath before new File. After join, require dst.getCanonicalFile() stays under appStoreDir.getCanonicalFile(). Unset spark.history.store.path stays the safe default.
32f7299601108917fb01920a54e084595b7b3bf8)HistoryServerDiskManager.scala appStorePath / Lease.commitFsHistoryProvider.scala diskManager / loadDiskStore / onApplicationStartJsonProtocol.scala applicationStartFromJsonUtils.scala sanitizeDirNameHistory.scala LOCAL_STORE_DIR (optional)GNU Affero GPL v3.0. See LICENSE. Loopback lab only. No warranty.