spring-boot-maven-plugin 2.0.0.RELEASE之前的版本打jar包classpath顺序错乱导致同包同名类覆盖失效

起因

Log4j出现了远程执行漏洞, 直接升级log4j版本不实现(启动会报错,新版的包结构可能有改变), 在github发现一个打补丁的方法, 就是用同名类覆盖JndiLookup类使其实例化报错.
在本地启用idea测试的时候非常顺利,包含Jndi地址的日志不会被解析而是直接打印出来. 于是便发包到服务器测试, 结果事与愿违, 漏洞还是能够触发.这确实不应该啊.

分析Jar包以及启动过程

Jar包内容

查看包的内容log4j-patchlog4j-core包都在, 所以可以初步确定是classpath顺序的问题. 查看包文件META-INf/MANIFEST.MF(相当于jar包描述文件)

Manifest-Version: 1.0
Archiver-Version: Plexus Archiver
Built-By: ***
Start-Class: ***.Application
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Spring-Boot-Version: 1.4.5.RELEASE
Created-By: Apache Maven 3.6.3
Build-Jdk: 1.8.0_131
Main-Class: org.springframework.boot.loader.JarLauncher

可以发现它的启动类是org.springframework.boot.loader.JarLauncher而jar包中确实有这个类的字节码,
spring-boot-loader

Jar包启动分析

通常,spring-boot-maven-plugin打的Jar包都是通过java -jar ***.jar 直接运行的,并没有直接添加classpath参数,所有可以猜测classpath是在运行jar包时指定的, 于是乎接下来就是debug了, 但是没有启动类org.springframework.boot.loader.JarLauncher源码啊, 这怎么好debug(其实没源码应该也能debug,只是比较麻烦), 想了想这是spring-boot-maven-plugin插件生成的jar包, spring-boot仓库应该是有源码的, 于是在github上一番通过包名一番搜索找到类非常相似的包类名(https://github.com/spring-projects/spring-boot/tree/main/spring-boot-project/spring-boot-tools/spring-boot-loader/src/main/java/org/springframework/boot/loader), github上默认是main(master)分支代码都比较新,所以通过tag切换到我所使用的版本(1.4.5.RELEASE),发现结构几乎和jar包相同, 可以肯定这就是源码了.
接着又面向百度编程找如何debug jar包程序

java -Xdebug -Xrunjdwp:transport=dt_socket,address=5005,server=y,suspend=y -jar ***.jar

在Idea中Edit Configurations 添加Remote JVM DEBUG配置,并设置源码路径,debug端口
进行debug

org.springframework.boot.loader.JarLauncher入口

public static void main(String[] args) throws Exception {new JarLauncher().launch(args);
}

调用的是父类org.springframework.boot.loader.Launcherlaunch(java.lang.String[])方法

protected void launch(String[] args) throws Exception {JarFile.registerUrlProtocolHandler();ClassLoader classLoader = createClassLoader(getClassPathArchives());launch(args, getMainClass(), classLoader);
}

org.springframework.boot.loader.JarLauncher的父类
org.springframework.boot.loader.ExecutableArchiveLauncher

@Override
protected List<Archive> getClassPathArchives() throws Exception {List<Archive> archives = new ArrayList<Archive>(this.archive.getNestedArchives(new EntryFilter() {@Overridepublic boolean matches(Entry entry) {return isNestedArchive(entry);}}));postProcessClassPathArchives(archives);return archives;
}

getClassPathArchives获取了所有jar依赖包信息.
其中this.archiveorg.springframework.boot.loader.archive.JarFileArchive类实例, 参考org.springframework.boot.loader.ExecutableArchiveLauncher的父类org.springframework.boot.loader.LaunchercreateArchive方法

/** Copyright 2012-2016 the original author or authors.** Licensed under the Apache License, Version 2.0 (the "License");* you may not use this file except in compliance with the License.* You may obtain a copy of the License at**      http://www.apache.org/licenses/LICENSE-2.0** Unless required by applicable law or agreed to in writing, software* distributed under the License is distributed on an "AS IS" BASIS,* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.* See the License for the specific language governing permissions and* limitations under the License.*/package org.springframework.boot.loader;import java.io.File;
import java.net.URI;
import java.net.URL;
import java.security.CodeSource;
import java.security.ProtectionDomain;
import java.util.ArrayList;
import java.util.List;import org.springframework.boot.loader.archive.Archive;
import org.springframework.boot.loader.archive.ExplodedArchive;
import org.springframework.boot.loader.archive.JarFileArchive;
import org.springframework.boot.loader.jar.JarFile;/*** Base class for launchers that can start an application with a fully configured* classpath backed by one or more {@link Archive}s.** @author Phillip Webb* @author Dave Syer*/
public abstract class Launcher {/*** Launch the application. This method is the initial entry point that should be* called by a subclass {@code public static void main(String[] args)} method.* @param args the incoming arguments* @throws Exception if the application fails to launch*/protected void launch(String[] args) throws Exception {JarFile.registerUrlProtocolHandler();ClassLoader classLoader = createClassLoader(getClassPathArchives());launch(args, getMainClass(), classLoader);}/*** Create a classloader for the specified archives.* @param archives the archives* @return the classloader* @throws Exception if the classloader cannot be created*/protected ClassLoader createClassLoader(List<Archive> archives) throws Exception {List<URL> urls = new ArrayList<URL>(archives.size());for (Archive archive : archives) {urls.add(archive.getUrl());}return createClassLoader(urls.toArray(new URL[urls.size()]));}/*** Create a classloader for the specified URLs.* @param urls the URLs* @return the classloader* @throws Exception if the classloader cannot be created*/protected ClassLoader createClassLoader(URL[] urls) throws Exception {return new LaunchedURLClassLoader(urls, getClass().getClassLoader());}/*** Launch the application given the archive file and a fully configured classloader.* @param args the incoming arguments* @param mainClass the main class to run* @param classLoader the classloader* @throws Exception if the launch fails*/protected void launch(String[] args, String mainClass, ClassLoader classLoader)throws Exception {Thread.currentThread().setContextClassLoader(classLoader);createMainMethodRunner(mainClass, args, classLoader).run();}/*** Create the {@code MainMethodRunner} used to launch the application.* @param mainClass the main class* @param args the incoming arguments* @param classLoader the classloader* @return the main method runner*/protected MainMethodRunner createMainMethodRunner(String mainClass, String[] args,ClassLoader classLoader) {return new MainMethodRunner(mainClass, args);}/*** Returns the main class that should be launched.* @return the name of the main class* @throws Exception if the main class cannot be obtained*/protected abstract String getMainClass() throws Exception;/*** Returns the archives that will be used to construct the class path.* @return the class path archives* @throws Exception if the class path archives cannot be obtained*/protected abstract List<Archive> getClassPathArchives() throws Exception;protected final Archive createArchive() throws Exception {ProtectionDomain protectionDomain = getClass().getProtectionDomain();CodeSource codeSource = protectionDomain.getCodeSource();URI location = (codeSource == null ? null : codeSource.getLocation().toURI());String path = (location == null ? null : location.getSchemeSpecificPart());if (path == null) {throw new IllegalStateException("Unable to determine code source archive");}File root = new File(path);if (!root.exists()) {throw new IllegalStateException("Unable to determine code source archive from " + root);}return (root.isDirectory() ? new ExplodedArchive(root): new JarFileArchive(root));}}

createClassLoader方法用到了org.springframework.boot.loader.LaunchedURLClassLoader类,而这个类继承了java.net.URLClassLoader, 而org.springframework.boot.loader.Launcher#launch(java.lang.String[], java.lang.String, java.lang.ClassLoader)方法修改了当前线程的classLoader, 相当于指定了classpath, 接着便是反射调用我们真正的SpringBoot启动类了

protected void launch(String[] args, String mainClass, ClassLoader classLoader)throws Exception {Thread.currentThread().setContextClassLoader(classLoader);createMainMethodRunner(mainClass, args, classLoader).run();
}

通过JDK文档得知java.net.URLClassLoader加载类的顺序和构造参数urls顺序相关

/*** Constructs a new URLClassLoader for the given URLs. The URLs will be* searched in the order specified for classes and resources after first* searching in the specified parent class loader. Any URL that ends with* a '/' is assumed to refer to a directory. Otherwise, the URL is assumed* to refer to a JAR file which will be downloaded and opened as needed.** 

If there is a security manager, this method first* calls the security manager's {@code checkCreateClassLoader} method* to ensure creation of a class loader is allowed.** @param urls the URLs from which to load classes and resources* @param parent the parent class loader for delegation* @exception SecurityException if a security manager exists and its* {@code checkCreateClassLoader} method doesn't allow* creation of a class loader.* @exception NullPointerException if {@code urls} is {@code null}.* @see SecurityManager#checkCreateClassLoader*/ public URLClassLoader(URL[] urls, ClassLoader parent) {super(parent);// this is to make the stack depth consistent with 1.1SecurityManager security = System.getSecurityManager();if (security != null) {security.checkCreateClassLoader();}this.acc = AccessController.getContext();ucp = new URLClassPath(urls, acc); }

而这个urls顺序来自org.springframework.boot.loader.ExecutableArchiveLauncher#getClassPathArchives方法得到的List的顺序

接下来看看org.springframework.boot.loader.archive.JarFileArchive#getNestedArchives方法

public List<Archive> getNestedArchives(EntryFilter filter) throws IOException {List<Archive> nestedArchives = new ArrayList<Archive>();for (Entry entry : this) {if (filter.matches(entry)) {nestedArchives.add(getNestedArchive(entry));}}return Collections.unmodifiableList(nestedArchives);
}

它遍历了自己(它实现了java.lang.Iterable)

@Override
public Iterator<Entry> iterator() {return new EntryIterator(this.jarFile.entries());
}

而这个jarFile就是我们这个jar程序包, 它是org.springframework.boot.loader.jar.JarFile类型, 也是java.util.jar.JarFile的子类

@Override
public Enumeration<java.util.jar.JarEntry> entries() {final Iterator<JarEntry> iterator = this.entries.iterator();return new Enumeration<java.util.jar.JarEntry>() {@Overridepublic boolean hasMoreElements() {return iterator.hasNext();}@Overridepublic java.util.jar.JarEntry nextElement() {return iterator.next();}};
}

其中iterator()方法

@Override
public Iterator<JarEntry> iterator() {return new EntryIterator();
}
private class EntryIterator implements Iterator<JarEntry> {private int index = 0;@Overridepublic boolean hasNext() {return this.index < JarFileEntries.this.size;}@Overridepublic JarEntry next() {if (!hasNext()) {throw new NoSuchElementException();}int entryIndex = JarFileEntries.this.positions[this.index];this.index++;return getEntry(entryIndex, JarEntry.class, false);}}
private JarFile(RandomAccessDataFile rootFile, String pathFromRoot,RandomAccessData data, JarEntryFilter filter, JarFileType type)throws IOException {super(rootFile.getFile());this.rootFile = rootFile;this.pathFromRoot = pathFromRoot;CentralDirectoryParser parser = new CentralDirectoryParser();// 重点this.entries = parser.addVisitor(new JarFileEntries(this, filter));parser.addVisitor(centralDirectoryVisitor());this.data = parser.parse(data, filter == null);this.type = type;
}

这里面的东西有点乱, 主要看下面这个方法(org.springframework.boot.loader.jar.CentralDirectoryParser#parseEntries)

private void parseEntries(CentralDirectoryEndRecord endRecord,RandomAccessData centralDirectoryData) throws IOException {byte[] bytes = Bytes.get(centralDirectoryData);CentralDirectoryFileHeader fileHeader = new CentralDirectoryFileHeader();int dataOffset = 0;for (int i = 0; i < endRecord.getNumberOfRecords(); i++) {fileHeader.load(bytes, dataOffset, null, 0, null);visitFileHeader(dataOffset, fileHeader);dataOffset += this.CENTRAL_DIRECTORY_HEADER_BASE_SIZE+ fileHeader.getName().length() + fileHeader.getComment().length()+ fileHeader.getExtra().length;}
}

总结一下: List是根据jar程序包中依赖包文件地址顺序而来的, 所以要想知道classpath顺序,得知道打包jar程序包是依赖包的写入文件的顺序, 所以问题出在spring-boot-maven-plugin打包项目上



spring-boot-maven-plugin 打包分析

项目用的是spring-boot-maven-pluginrepackage来打成可执行包, 不多说, 还是debug

maven 打包debug (默认是8000端口)

mvnDebug -DskipTests=true package

关键方法
org.springframework.boot.maven.RepackageMojo#repackage

private void repackage() throws MojoExecutionException {File source = this.project.getArtifact().getFile();File target = getTargetFile();Repackager repackager = getRepackager(source);// this.project 是maven传递给插件的// this.project.getArtifacts() 获取项目的所有依赖Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(),getFilters(getAdditionalFilters()));Libraries libraries = new ArtifactsLibraries(artifacts, this.requiresUnpack,getLog());try {LaunchScript launchScript = getLaunchScript();repackager.repackage(target, libraries, launchScript);}catch (IOException ex) {throw new MojoExecutionException(ex.getMessage(), ex);}updateArtifact(source, target, repackager.getBackupFile());
}

其中 this.project.getArtifacts() 是获取项目的所有依赖, 注意它返回的是LinkedHashSet它是有顺序的,而且是按照maven的依赖规则生成的顺序, 但是在我调试的时候filterDependencies方法返回的是HashSet

org.springframework.boot.maven.AbstractDependencyFilterMojo#filterDependencies

protected Set<Artifact> filterDependencies(Set<Artifact> dependencies,FilterArtifacts filters) throws MojoExecutionException {try {return filters.filter(dependencies);}catch (ArtifactFilterException e) {throw new MojoExecutionException(e.getMessage(), e);}
}

通过查看代码发现源码中使用的依赖过滤器返回的都是HashSet

  • org.apache.maven.shared.artifact.filter.collection.ScopeFilter
  • org.apache.maven.shared.artifact.filter.collection.ArtifactIdFilter
  • org.springframework.boot.maven.MatchingGroupIdFilter
  • org.springframework.boot.maven.ExcludeFilter
  • org.springframework.boot.maven.IncludeFilter


生成Jar包时的依赖包的写入顺序

org.springframework.boot.loader.tools.Repackager

private void repackage(JarFile sourceJar, File destination, Libraries libraries,LaunchScript launchScript) throws IOException {JarWriter writer = new JarWriter(destination, launchScript);try {final List<Library> unpackLibraries = new ArrayList<Library>();final List<Library> standardLibraries = new ArrayList<Library>();// 重点关注doWithLibrarieslibraries.doWithLibraries(new LibraryCallback() {@Overridepublic void library(Library library) throws IOException {File file = library.getFile();if (isZip(file)) {if (library.isUnpackRequired()) {unpackLibraries.add(library);}else {standardLibraries.add(library);}}}});writer.writeManifest(buildManifest(sourceJar));Set<String> seen = new HashSet<String>();writeNestedLibraries(unpackLibraries, seen, writer);if (this.layout instanceof RepackagingLayout) {writer.writeEntries(sourceJar,new RenamingEntryTransformer(((RepackagingLayout) this.layout).getRepackagedClassesLocation()));}else {writer.writeEntries(sourceJar);}writeNestedLibraries(standardLibraries, seen, writer);if (this.layout.isExecutable()) {writer.writeLoaderClasses();}}finally {try {writer.close();}catch (Exception ex) {// Ignore}}
}// doWithLibraries 得到的包按序写入jar文件
private void writeNestedLibraries(List<Library> libraries, Set<String> alreadySeen,JarWriter writer) throws IOException {for (Library library : libraries) {String destination = Repackager.this.layout.getLibraryDestination(library.getName(), library.getScope());if (destination != null) {if (!alreadySeen.add(destination + library.getName())) {throw new IllegalStateException("Duplicate library " + library.getName());}writer.writeNestedLibrary(destination, library);}}
}

org.springframework.boot.maven.ArtifactsLibraries#doWithLibraries是对HashSet进行遍历的,所以写入依赖包的顺序是不确定的, 这就导致使用该插件打的jar包在运行时无法确定依赖的引入顺序,从而导致同包同名类覆盖失效

@Override
public void doWithLibraries(LibraryCallback callback) throws IOException {// this.artifacts 是 `org.springframework.boot.maven.RepackageMojo#repackage` 传过来的.其类型为HashSetSet<String> duplicates = getDuplicates(this.artifacts);for (Artifact artifact : this.artifacts) {LibraryScope scope = SCOPES.get(artifact.getScope());if (scope != null && artifact.getFile() != null) {String name = getFileName(artifact);if (duplicates.contains(name)) {this.log.debug("Duplicate found: " + name);name = artifact.getGroupId() + "-" + name;this.log.debug("Renamed to: " + name);}callback.library(new Library(name, artifact.getFile(), scope,isUnpackRequired(artifact)));}}
}

修复

查看github上spring-boot项目源码, 发现它在 2.0.0.RELEASE及之后的版本修复了这个bug,
但是我没在它的commit中找到提及这个bug的相关信息
org.springframework.boot.maven.RepackageMojo

private void repackage() throws MojoExecutionException {Artifact source = getSourceArtifact();File target = getTargetFile();Repackager repackager = getRepackager(source.getFile());Libraries libraries = getLibraries(this.requiresUnpack);try {LaunchScript launchScript = getLaunchScript();repackager.repackage(target, libraries, launchScript, parseOutputTimestamp());}catch (IOException ex) {throw new MojoExecutionException(ex.getMessage(), ex);}updateArtifact(source, target, repackager.getBackupFile());
}

org.springframework.boot.maven.AbstractPackagerMojo

protected final Libraries getLibraries(Collection<Dependency> unpacks) throws MojoExecutionException {Set<Artifact> artifacts = filterDependencies(this.project.getArtifacts(), getFilters(getAdditionalFilters()));return new ArtifactsLibraries(artifacts, unpacks, getLog());
}

org.springframework.boot.maven.AbstractDependencyFilterMojo#filterDependencies

protected Set<Artifact> filterDependencies(Set<Artifact> dependencies, FilterArtifacts filters)throws MojoExecutionException {try {// dependencies 本身也是LinkedHashSet// 过滤这里使用LinkedHashSet,这样就和maven原来解析依赖的顺序一致Set<Artifact> filtered = new LinkedHashSet<>(dependencies);filtered.retainAll(filters.filter(dependencies));return filtered;}catch (ArtifactFilterException ex) {throw new MojoExecutionException(ex.getMessage(), ex);}
}

总结

spring-boot-maven-plugin低于2.0.0.RELEASE时打的包是有一定问题的, 它指定classpath的顺序没有按maven的依赖规则, 它只能确保依赖包是那几个, 不能确定依赖包的引入顺序. 对于项目有同包同名类时(先引入的依赖中的类生效),这是有问题的. 对于spring-boot1.x, 如果使用spring-boot-maven-plugin打包最好还是使用2.0.0.RELEASE及以上版本.


本文来自互联网用户投稿,文章观点仅代表作者本人,不代表本站立场,不承担相关法律责任。如若转载,请注明出处。 如若内容造成侵权/违法违规/事实不符,请点击【内容举报】进行投诉反馈!

相关文章

立即
投稿

微信公众账号

微信扫一扫加关注

返回
顶部