kaikramer / keystore-explorer

KeyStore Explorer is a free GUI replacement for the Java command-line utilities keytool and jarsigner.
https://keystore-explorer.org/
GNU General Public License v3.0
1.7k stars 275 forks source link

Spring boot app failed to start after signing - Exception in thread "main" java.lang.ClassNotFoundException: com.test.App #291

Closed pbeast closed 3 years ago

pbeast commented 3 years ago

Describe the bug JAVA application that uses spring boot fails to start after signing because unable to find mainClass:

Exception in thread "main" java.lang.ClassNotFoundException: com.test.App
        at java.base/java.net.URLClassLoader.findClass(Unknown Source)
        at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
        at org.springframework.boot.loader.LaunchedURLClassLoader.loadClass(LaunchedURLClassLoader.java:151)
        at java.base/java.lang.ClassLoader.loadClass(Unknown Source)
        at java.base/java.lang.Class.forName0(Native Method)
        at java.base/java.lang.Class.forName(Unknown Source)
        at org.springframework.boot.loader.MainMethodRunner.run(MainMethodRunner.java:46)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:107)
        at org.springframework.boot.loader.Launcher.launch(Launcher.java:58)
        at org.springframework.boot.loader.JarLauncher.main(JarLauncher.java:88)

Test project - https://drive.google.com/file/d/1tXtQDw41_SY21nYyRg6IBaB375YTJxUX/view?usp=sharing Unsigned file - test/target/test.jar Signed file - test/target/test-signed.jar

To Reproduce Steps to reproduce the behavior:

  1. Package the app - mvn package --file pom.xml
  2. Run the app - java -jar target/test.jar
  3. Check output - Hello World!
  4. Sign the target/test.jar
  5. Check the signature - jarsigner -verify target/test-signed.jar
  6. Check output - jar verified. (additional options -verbose -certs provide expected info)
  7. Run the signed app - java -jar target/test-signed.jar
  8. Java exception displayed

Expected behavior Signed JAR should be runnable

Environment

P.S. Huge thanks for the wonderful app!

kaikramer commented 3 years ago

And no issues when you sign the jar with jarsigner?

pbeast commented 3 years ago

Yep, works perfectly. I unpacked both of the archives but failed to identify the difference the "main" class is present at the same location, and the manifest has all of the required entries

pbeast commented 3 years ago

Ah, btw, I thought maybe the classes are transferred incorrectly between JARs, so I peeked on jarsigner implementation. I changed your code a bit but that had no effect. Anyway, maybe it worse a PR:

    JarEntry newJarEntry = new JarEntry(jarEntry.getName());
    newJarEntry.setMethod(jarEntry.getMethod());
    newJarEntry.setTime(jarEntry.getTime());
    newJarEntry.setComment(jarEntry.getComment());
    newJarEntry.setExtra(jarEntry.getExtra());
    if (jarEntry.getMethod() == JarEntry.STORED) {
        newJarEntry.setSize(jarEntry.getSize());
        newJarEntry.setCrc(jarEntry.getCrc());
    }
    // newJarEntry.setCompressedSize(jarEntry.getCompressedSize());
    jos.putNextEntry(newJarEntry);

    try (InputStream jis = jar.getInputStream(jarEntry)) {
        jis.transferTo(jos);
        // byte[] buffer = new byte[2048];
        // int read;

        // while ((read = jis.read(buffer)) != -1) {
        //     jos.write(buffer, 0, read);
        // }

        jos.closeEntry();
    }
kaikramer commented 3 years ago

That's a very interesting bug, but unfortunately it's already quite late here. I'll take a closer look tomorrow.

pbeast commented 3 years ago

Thanks a lot!!!

kaikramer commented 3 years ago

The issue is caused by KSE not transferring the directory entries of the jar. I have a fixed version of JarSigner locally, but need to do more testing before I can commit it to GitHub.

Thanks for reporting!

pbeast commented 3 years ago

🙂 I came to the same conclusion but decided to wait for more qualified opinion. Want to share the fixing code and I will do some testing in parallel?

kaikramer commented 3 years ago

Sure:

    /*
     * Write out all JAR entries from source JAR to output stream excepting
     * manifest and existing signature files for the supplied signature name
     */
    private static void writeJarEntries(JarFile jar, JarOutputStream jos, String signatureName) throws IOException {

        for (Enumeration<?> jarEntries = jar.entries(); jarEntries.hasMoreElements();) {
            JarEntry jarEntry = (JarEntry) jarEntries.nextElement();
            if (!jarEntry.isDirectory()) {
                String entryName = jarEntry.getName();

                // Signature files not to write across
                String sigFileLocation = MessageFormat.format(METAINF_FILE_LOCATION, signatureName, SIGNATURE_EXT)
                        .toUpperCase();
                String dsaSigBlockLocation = MessageFormat.format(METAINF_FILE_LOCATION, signatureName,
                        DSA_SIG_BLOCK_EXT);
                String rsaSigBlockLocation = MessageFormat.format(METAINF_FILE_LOCATION, signatureName,
                        RSA_SIG_BLOCK_EXT);

                // Do not write across existing manifest or matching signature files
                if ((!entryName.equalsIgnoreCase(MANIFEST_LOCATION)) && (!entryName.equalsIgnoreCase(sigFileLocation))
                        && (!entryName.equalsIgnoreCase(dsaSigBlockLocation))
                        && (!entryName.equalsIgnoreCase(rsaSigBlockLocation))) {
                    // New JAR entry based on original
                    transferJarEntry(jar, jos, jarEntry);
                }
            } else {
                transferJarEntry(jar, jos, jarEntry);
            }
        }
    }

    private static void transferJarEntry(JarFile jar, JarOutputStream jos, JarEntry jarEntry) throws IOException {
        JarEntry newJarEntry = new JarEntry(jarEntry.getName());
        newJarEntry.setMethod(jarEntry.getMethod());
        newJarEntry.setCompressedSize(jarEntry.getCompressedSize());
        newJarEntry.setCrc(jarEntry.getCrc());
        jos.putNextEntry(newJarEntry);

        try (InputStream jis = jar.getInputStream(jarEntry)) {
            IOUtils.copy(jis, jos);
            jos.closeEntry();
        }
    }
pbeast commented 3 years ago

Works like a charm! Thanks again Also, I combined the fix with my changes (the ones I snitched from the jarsiger) and think it's a bit better approach for handling STORED entries:

private static void transferJarEntry(JarFile jar, JarOutputStream jos, JarEntry jarEntry) throws IOException {
        JarEntry newJarEntry = new JarEntry(jarEntry.getName());
        newJarEntry.setMethod(jarEntry.getMethod());
        newJarEntry.setTime(jarEntry.getTime());
        newJarEntry.setComment(jarEntry.getComment());
        newJarEntry.setExtra(jarEntry.getExtra());
        if (jarEntry.getMethod() == JarEntry.STORED) {
            newJarEntry.setSize(jarEntry.getSize());
            newJarEntry.setCrc(jarEntry.getCrc());
        }
        jos.putNextEntry(newJarEntry);

        try (InputStream jis = jar.getInputStream(jarEntry)) {
            jis.transferTo(jos);
            jos.closeEntry();
        }
    }

Also, the jis.transferTo(jos); do exactly the same as IOUtils.copy but uses bigger buffer - 8192

kaikramer commented 3 years ago

Ah, I forgot to mention this: Currently KSE has to remain compatible with Java 8. transferTo() was introduced with Java 9. But I'll adopt the "STORE part". Thanks :-)

pbeast commented 3 years ago

That makes sense :-) Thanks again for your support