起因
Log4j出现了远程执行漏洞, 直接升级log4j版本不实现(启动会报错,新版的包结构可能有改变), 在github发现一个打补丁的方法, 就是用同名类覆盖JndiLookup
类使其实例化报错.
在本地启用idea测试的时候非常顺利,包含Jndi地址的日志不会被解析而是直接打印出来. 于是便发包到服务器测试, 结果事与愿违, 漏洞还是能够触发.这确实不应该啊.
分析Jar包以及启动过程
Jar包内容
查看包的内容log4j-patch
和log4j-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包中确实有这个类的字节码,
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.Launcher
的launch(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.archive
是org.springframework.boot.loader.archive.JarFileArchive
类实例, 参考org.springframework.boot.loader.ExecutableArchiveLauncher
的父类org.springframework.boot.loader.Launcher
中createArchive
方法
/*
* 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-plugin
的repackage
来打成可执行包, 不多说, 还是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-boot
1.x, 如果使用spring-boot-maven-plugin
打包最好还是使用2.0.0.RELEASE
及以上版本.