
Apache Spark 4.2.0 - High - Remote - File read - History zip follows planted symlink
abraxaslabs.tech · github.com/abraxas · @abraxas_null · [email protected] · spark-history-zip-symlink
Class: File read Reach: Remote
Apache Spark 4.2.0 (32f72996) - Apache Software Foundation
CVE-2025-54920 locked History Server Jackson so sparkEventFromJson only constructs SparkListenerEvent subclasses. Rolling zipEventLogFiles still dumps every listStatus child via FileSystem.open. LocalFileSystem follows a POSIX symlink. A planted child in eventlog_v2_* is packed into unauth GET /api/v1/applications/{id}/logs. The zip member body is the SHS-UID target. The entry name is the symlink basename.
This is not a re-file of the Jackson RCE. Jackson stays closed. This is not host RCE. Integrity and availability are none.
| ID | no CVE yet |
| Class | File read (rolling zip leftover of the CVE-2025-54920 victim model) |
| Reach | Remote (writer of the History log dir plus unauth GET of the zip) |
| CWE | CWE-59, CWE-22 |
| CVSS | High: 7.5 CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:N |
| Product | Apache Spark |
| Affected | 4.2.0 tag v4.2.0 (32f7299601108917fb01920a54e084595b7b3bf8) |
| Auth | PR:L = write to spark.history.fs.logDirectory (same victim as 54920). History UI ACLs default off. |
| License | GNU Affero GPL v3.0 |
| Lab | 127.0.0.1:18980 only |
Someone who can write spark.history.fs.logDirectory (default file:/tmp/spark-events) can plant a rolling dir the History Server will zip. Rolling writers default on. History UI ACLs default off. The SHS process UID is the confused deputy.
| Privilege | Method / sink | Lab-proven or inferred |
|---|---|---|
| Confidentiality: read a host file the SHS UID can open | Plant eventlog_v2_<id>/ with valid appstatus_* plus events_* JSON (SparkListenerLogStart / SparkListenerApplicationStart) plus a symlink child whose target sits outside the log dir. Wait for scan. Unauth GET /api/v1/applications/{id}/logs (OneApplicationResource.getEventLogs -> FsHistoryProvider.writeEventLogs -> RollingEventLogFilesFileReader.zipEventLogFiles -> FileSystem.open) | Lab-proven. Zip member eventlog_v2_app-2026-0001/planted-secret body is SPARK-HISTORY-ZIP-SYMLINK-WITNESS. Events member has no witness. Entry name stays the symlink basename, not the target path. |
Confidentiality: sibling tenant event log the attacker cannot open (660) | Same zip, symlink target is another app's log | Inferred. Same FileSystem.open. Not exercised. |
Confidentiality: keytab, spark-env.sh, /etc/passwd if SHS can read them | Same zip | Inferred. Oracle is a dedicated /secret/host-secret, not those files. |
| Integrity of SHS or host files | zip is read into the HTTP body | Closed. Lab-proven none. |
| Availability of SHS or host files | zip does not delete or truncate the target | Closed. Lab-proven none. |
| Host RCE / Jackson gadget construct | JsonProtocol.sparkEventFromJson | Closed. SparkListenerEvent required. This lab plants only benign Event JSON. |
Zip slip of ../ in the entry name | Path.getName() | Closed. POSIX filename cannot contain /. Entry is eventlog_v2_<id>/<basename>. |
Path-string escape of resolveLogPath | new Path(logDir, attempt.logPath) | Closed. The Path object stays under the configured log dir. The escape is the inode. |
| Compaction rewrite / cleaner delete of the target | spark.history.fs.eventLog.rolling.maxFilesToRetain default Integer.MAX_VALUE; cleaner default off | Closed. Local fs.delete of a symlink unlinks the link, not the target. |
| Unauth job submit / ML model load | SECURITY.md FAQ / docs/ml-security.md | Closed. By design, not this leftover. |
I pinned apache/spark v4.2.0 (32f7299601108917fb01920a54e084595b7b3bf8) rather than master or a 4.3 RC. Twenty-eight hunts. Jackson History RCE is closed on this peel (JsonProtocol.scala:965-972). The History Server still pointed at an attacker-writable log dir, which is the same victim model as CVE-2025-54920.
zipEventLogFiles zips all files, not only event files:
override def zipEventLogFiles(zipStream: ZipOutputStream): Unit = {
val dirEntryName = rootPath.getName + "/"
zipStream.putNextEntry(new ZipEntry(dirEntryName))
files.foreach { file =>
addFileAsZipEntry(zipStream, file.getPath, dirEntryName + file.getPath.getName)
}
}
addFileAsZipEntry is fileSystem.open then transferTo. LocalFileSystem follows POSIX symlinks. getLogDirInfo comments "handles symbolic links properly" and then walks unresolved Path parents for a display name.
Wrong turns already recorded: treating SPARK-52381 as the whole History story; planting the witness in events_* (the lab fails that); expecting the zip entry to carry the target path; treating resolveLogPath prefix-join as the escape; trying a HiveConnection Event class on this pin.
python3 spark-history-zip-symlink-Abraxas-Labs.py
or
cd lab
./run.sh
Gadget of EventLogFileReaders.scala rolling zip dump, not a full History Server and not the official spark:4.2.0 image. It does not parse Event JSON through Jackson. Loopback 127.0.0.1:18980. Compose project spark-history-zip-symlink. Rolling dir holds valid appstatus_* + events_* (no witness) plus planted-secret -> /secret/host-secret.
SUCCESS SPARK-HISTORY-ZIP-SYMLINK SPARK-HISTORY-ZIP-SYMLINK-WITNESS
Pack only events_* / appstatus_* children, not every listStatus name. Open with no-follow (O_NOFOLLOW / Hadoop no-symlink options) and reject a child whose realpath is outside spark.history.fs.logDirectory. A display-name helper is not a realpath check.
32f7299601108917fb01920a54e084595b7b3bf8)EventLogFileReaders.scala (addFileAsZipEntry ~48-56, zipEventLogFiles ~267-272)FsHistoryProvider.scala (writeEventLogs ~831-856, resolveLogPath ~866-896)OneApplicationResource.scala (GET .../logs ~128-165)JsonProtocol.scala (sparkEventFromJson ~965-972, Jackson lock)