PS4 BD-J privilege escalation using nested JAR
Summary by PlayStation
Affected PS4 system software: version 13.00 to latest (13.02).
Core Issue:
- Security Policy (
BdjPolicyImpl): Canonicalizes the URL path usingFile.getCanonicalPath(), which resolves..sequences - Class Loader (
JarZipFile): Uses the URL path as a literal JAR entry name, where..are just characters
Result: Code can appear to load from the trusted
lib/ext directory while actually loading from an untrusted nested JAR on the Blu-ray disc.Environment Configuration
1javaHome = "/app0/bdjstack/"
2vfsRoot = "/VP" (which is proxy for /disc)
The Vulnerability
Base Path:
file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jarThis URL triggers two different interpretations:
-
Security Policy Path: Canonicalizes to
/app0/bdjstack/lib/ext/00000.jar- Matches trusted
lib/extdirectory - Result: AllPermission granted
- Matches trusted
-
Actual Loading Path: Nested JAR entry
../../app0/bdjstack/lib/ext/00000.jar- Located inside
/VP/BDMV/JAR/00000.jar - Result: Loads from untrusted disc content
- Located inside
Complete Code Flow Analysis
- BdjPolicyImpl Security Check
1public class BdjPolicyImpl extends Policy {
2 private static String javaHome = System.getProperty("java.home");
3 // javaHome = "/app0/bdjstack/"
4
5 public PermissionCollection getPermissions(CodeSource paramCodeSource) {
6 if (paramCodeSource != null) {
7 URL uRL = paramCodeSource.getLocation();
8 // uRL = "file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
9
10 if (uRL.getProtocol().equals("file")) {
11 try {
12 // CRITICAL: File canonicalization happens here!
13 String str = (new File(uRL.getFile())).getCanonicalPath();
14 // uRL.getFile() = "/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
15 // Canonical path = "/app0/bdjstack/lib/ext/00000.jar"
16
17 if (str.startsWith(javaHome + "lib" + File.separator + "ext")) {
18 // "/app0/bdjstack/lib/ext/00000.jar".startsWith("/app0/bdjstack/lib/ext")
19 // TRUE - Grants AllPermission!
20
21 Permissions permissions1 = new Permissions();
22 permissions1.add(new AllPermission());
23 return permissions1;
24 }
25 } catch (Exception exception) {}
26 }
27 }
28 // ... return normal permissions
29 }
30}
Security Check Result: AllPermission Granted
The security policy believes the code is loaded from the trusted
lib/ext directory because:-
URL contains path traversal:
/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar -
File canonicalization resolves
../../:/app0/bdjstack/lib/ext/00000.jar -
Canonicalized path starts with
javaHome + "lib/ext":/app0/bdjstack/lib/ext -
Code is deemed trusted →
AllPermissiongranted -
Actual Class Loading Path
Meanwhile, the actual class loading follows a completely different path:
Step 2.1: URL Registration
1// CoreApp.loadXlet()
2String basePath = getBasePath(paramLong1);
3// Returns: "file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
4
5// generateClasspath() detects ":" in path
6if (paramString.indexOf(":") != -1) {
7 return paramString; // Returns unchanged!
8}
9
10// XletClassLoader created
11URL[] arrayOfURL = { new URL("file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar") };
12xletClassLoader = new XletClassLoader(arrayOfURL, false); // isSigned = false
Step 2.2: BDJFactory Proxy Routing
1// When URLClassPath tries to open the JAR
2String path = url.getFile();
3// path = "/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
4
5if (BDJFactory.needProxy(path)) {
6 // Returns TRUE for /dsm/ paths
7 return factory.getZipFileDescriptor(appKey, path, mode);
8}
Step 2.3: JarDescriptorFactory Path Transformation
1public BDJZipFileProxy getZipFileDescriptor(int paramInt1, String paramString, int paramInt2) {
2 String str = getAbsPath(paramString);
3 // Input: "/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
4 return new JarZipFile(str, paramInt2, paramString);
5}
6
7private String getAbsPath(String paramString) {
8 // Branch 1: /dsm/ path transformation
9 if (paramString.startsWith("/dsm/") &&
10 !paramString.startsWith("/dsm/app_base/")) {
11
12 // Strip "/dsm/" prefix
13 String relative = paramString.substring(5);
14 // relative = "00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
15
16 // Prepend vfsRoot + "BDMV/JAR/"
17 str = vfsRoot + "BDMV/JAR/" + relative;
18 // str = "/VP/BDMV/JAR/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
19 }
20 return str;
21}
Step 2.4: JarZipFile Nested JAR Handling
1public JarZipFile(String paramString1, int paramInt, String paramString2) throws IOException {
2 // paramString1 = "/VP/BDMV/JAR/00000.jar/../../app0/bdjstack/lib/ext/00000.jar"
3
4 // Find FIRST ".jar/" separator
5 int i = paramString1.indexOf(".jar/"); // i = 19
6
7 // Split into outer JAR and entry name
8 String str1 = paramString1.substring(0, i + 4);
9 // str1 = "/VP/BDMV/JAR/00000.jar" ← Physical file on disc
10
11 String str2 = paramString1.substring(i + 5);
12 // str2 = "../../app0/bdjstack/lib/ext/00000.jar" ← Entry name (literal!)
13
14 // Open outer JAR
15 this.jf = new JarFile(str1, true);
16 // Opens: /VP/BDMV/JAR/00000.jar
17
18 // Look for entry by EXACT name
19 this.ze = this.jf.getJarEntry(str2);
20 // Searches for entry: "../../app0/bdjstack/lib/ext/00000.jar"
21
22 if (this.ze == null)
23 throw new FileNotFoundException(str2);
24
25 // Verify it's a ZIP file
26 DataInputStream dis = new DataInputStream(this.jf.getInputStream(this.ze));
27 if (dis.readInt() != 0x504B0304) // ZIP magic number
28 throw new ZipException("not a zip file");
29}
Actual Loading Result: Classes are loaded from nested JAR entry inside
/VP/BDMV/JAR/00000.jarStep 2.5: Class Loading from Nested JAR
1// JarZipFile.getEntry() - loads classes from nested JAR
2public ZipEntry getEntry(String paramString) {
3 // Open nested JAR as stream
4 JarInputStream jarInputStream = new JarInputStream(
5 this.jf.getInputStream(this.ze) // Entry: "../../app0/bdjstack/lib/ext/00000.jar"
6 );
7
8 // Find the class in the nested JAR
9 JarEntry jarEntry;
10 while ((jarEntry = jarInputStream.getNextJarEntry()) != null) {
11 if (paramString.equals(jarEntry.getName()))
12 return jarEntry;
13 }
14 return null;
15}
The Vulnerability
Time-of-Check / Time-of-Use (TOCTOU) Issue
| Stage | Path Used | Result |
|---|---|---|
| Security Check | /dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar | Canonicalized → /app0/bdjstack/lib/ext/00000.jar |
| Permission Decision | Matches javaHome + "lib/ext" | AllPermission Granted |
| Actual Loading | Entry ../../app0/bdjstack/lib/ext/00000.jar in /VP/BDMV/JAR/00000.jar | Untrusted nested JAR |
Why Unsigned JAR is Required
The BD-J stack has a bug in signature verification for nested JARs. When classes are loaded from a nested JAR accessed through
JarInputStream:1// XletClassLoader.defineClass1()
2Certificate[] arrayOfCertificate = paramResource.getCertificates();
3
4if (arrayOfCertificate != null && !paramResource.isBDJVerified()) {
5 arrayOfCertificate = null; // Signature verification fails
6}
Application ID Range
1// CoreAppId.java
2public boolean isSigned() {
3 return (16384 <= this.aid && this.aid <= 524287);
4 // 0x4000 0x7FFFF
5}
Signed Range:
0x4000 (16,384) to 0x7FFFF (524,287)To bypass signature verification, the
applicationId in the BDJO file must be outside this range:0x0000to0x3FFF(0 to 16,383)0x80000to0xFFFF(524,288 to 65,535)
When
isSigned() returns false, signature verification is skipped:1// XletClassLoader.needsToVerify()
2private boolean needsToVerify() {
3 return getAppContext().getCoreAppId().isSigned(); // Returns false
4}
5
6// XletClassLoader.getPermissions()
7protected PermissionCollection getPermissions(CodeSource paramCodeSource) {
8 if (needsToVerify() && !RootCertManager.inKeyStore(arrayOfCertificate)) {
9 throw new SecurityException("cannot load unsigned class.");
10 }
11 // Skipped when isSigned() = false
12}
Path Transformation Tables
Security Check Path:
1Input: file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar
2 ↓
3URL.getFile(): /dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar
4 ↓
5File(): File(/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar)
6 ↓
7Canonicalize: /app0/bdjstack/lib/ext/00000.jar
8 ↓
9Check: startsWith("/app0/bdjstack/lib/ext")
10 ↓
11Result: AllPermission
Class Loading Path:
1Input: file:/dsm/00000.jar/../../app0/bdjstack/lib/ext/00000.jar
2 ↓
3needProxy: TRUE (/dsm/ path)
4 ↓
5getAbsPath: /VP/BDMV/JAR/00000.jar/../../app0/bdjstack/lib/ext/00000.jar
6 ↓
7Split: Outer = /VP/BDMV/JAR/00000.jar
8 Entry = ../../app0/bdjstack/lib/ext/00000.jar
9 ↓
10Open: JarFile("/VP/BDMV/JAR/00000.jar")
11 ↓
12Lookup: getJarEntry("../../app0/bdjstack/lib/ext/00000.jar")
13 ↓
14Result: Load from nested JAR entry
Summary
This vulnerability demonstrates a critical flaw where:
-
Security Policy (
BdjPolicyImpl.getPermissions()):- Canonicalizes URL path using
File.getCanonicalPath() - Resolves
../../sequences - Checks against trusted
lib/extpath - Grants
AllPermissionbased on canonicalized path
- Canonicalizes URL path using
-
Class Loading (
JarZipFile):- Treats URL path as literal JAR entry name
../../are just characters, not path operators- Loads from nested JAR entry on Blu-ray disc
- Content is untrusted
-
Result:
- Security check sees:
/app0/bdjstack/lib/ext/00000.jar(trusted) - Actual loading from: Entry
../../app0/bdjstack/lib/ext/00000.jarin/VP/BDMV/JAR/00000.jar(untrusted) - Time-of-Check/Time-of-Use (TOCTOU) vulnerability
- Untrusted code obtains
AllPermission
- Security check sees:
Impact
Complete privilege escalation from sandboxed Blu-ray application to full system access with
AllPermission.Timeline
submitted a report to PlayStation.
December 5, 2025, 7:47am UTC HackerOne triage
changed the status to Pending program review
. posted a comment.
January 30, 2026, 12:42am UTC PlayStation staff
changed the status to Triaged. PlayStation staff
updated the severity to medium. rewarded gezine with a $2,500 bounty.
March 20, 2026, 11:49pm UTC posted a comment.
March 21, 2026, 12:37am UTC PlayStation staff
closed the report and changed the status to Resolved. PlayStation staff
posted a comment. requested to disclose this report.
March 31, 2026, 7:17pm UTC posted a comment.
March 31, 2026, 7:19pm UTC PlayStation staff
posted a comment. PlayStation staff
agreed to disclose this report. This report has been disclosed.
4 days ago posted a comment.
4 days ago PlayStation staff
posted a comment. PlayStation staff
posted a comment. posted a comment.
2 days ago