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() {

				@Override
				public 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.
 *
 * <p>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.1
    SecurityManager 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<Archive>的顺序

接下来看看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>() {

		@Override
		public boolean hasMoreElements() {
			return iterator.hasNext();
		}

		@Override
		public 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;

	@Override
	public boolean hasNext() {
		return this.index < JarFileEntries.this.size;
	}

	@Override
	public 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<Archive>是根据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<Artifact>它是有顺序的,而且是按照maven的依赖规则生成的顺序, 但是在我调试的时候filterDependencies方法返回的是HashSet<Artifact>

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>();
		// 重点关注doWithLibraries
		libraries.doWithLibraries(new LibraryCallback() {

			@Override
			public 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` 传过来的.其类型为HashSet
	Set<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及以上版本.

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值