前言

经典 Java 软件漏洞 struts 系列,准备一个个看过去。


环境搭建

struts 2.0.11.1 + tomcat7,源码部分不需要改变。

漏洞利用

阅读官方通告,漏洞原因在于 struts2 在读取静态文件时进行了一次 URL 解码,所以可以通过将 ../ 进行两次 URL 编码从而列目录或者访问其他目录中的文件。

测试 payload:

http://localhost:8080/struts/..%252f

可以看到列出了 tomcat 的 lib 目录,读取 class 文件:

http://localhost:8080/struts/..%252f..%252f..%252fWEB-INF/classes/com/example/struts2/LoginAction.class/

漏洞分析

在官方通告中已经明确指出了漏洞存在的类为 FilterDispatcher,我们直接调试即可,找到漏洞方法 doFilter:

if (serveStatic && resourcePath.startsWith("/struts")) {
    String name = resourcePath.substring("/struts".length());
    findStaticResource(name, request, response);
}

在访问前缀为 /struts 时,会去掉前缀然后调用 findStaticResource:

if (!name.endsWith(".class")) {
    for (String pathPrefix : pathPrefixes) {
        InputStream is = findInputStream(name, pathPrefix);
        ...
    }
    ...
}

访问的静态文件不能以 .class 结尾,其实这个限制没有什么用,然后遍历配置好的静态文件目录并调用 findInputStream,tomcat7 的环境下一共有三个目录:

org/apache/struts2/static/
template/
org/apache/struts2/interceptor/debugging/

官方给的 payload 只有其中的 template 能触发漏洞,在 findInputStream 方法中:

protected InputStream findInputStream(String name, String packagePrefix) throws IOException {
    String resourcePath;
    if (packagePrefix.endsWith("/") && name.startsWith("/")) {
        resourcePath = packagePrefix + name.substring(1);
    } else {
        resourcePath = packagePrefix + name;
    }

    resourcePath = URLDecoder.decode(resourcePath, encoding);

    return ClassLoaderUtil.getResourceAsStream(resourcePath, getClass());
}

将访问的路径跟目录拼接在一起,然后 URL 解码,再调用 getResourceAsStream 开始读取文件,就造成了目录遍历漏洞。

漏洞修复

加上了 URL.getFile 和 endWith 来判断后缀。

其他的思考

为什么 LoginAction.class 后面加上一个 / 还能读取到文件

因为最后会调用 WinNTFileSystem 类的 normalize 方法对路径进行调整:

public String normalize(String path) {
    int n = path.length();
    char slash = this.slash;
    char altSlash = this.altSlash;
    char prev = 0;
    for (int i = 0; i < n; i++) {
        char c = path.charAt(i);
        if (c == altSlash)
        return normalize(path, n, (prev == slash) ? i - 1 : i);
        if ((c == slash) && (prev == slash) && (i > 1))
        return normalize(path, n, i - 1);
        if ((c == ':') && (i > 1))
        return normalize(path, n, 0);
        prev = c;
    }
    if (prev == slash) return normalize(path, n, n - 1);
    return path;
}

会消除掉最后面的所有 /。

为什么回退一次目录会访问到 tomcat 的 lib 目录

调试可以发现,findResource 可以用来获取类,而根据资源路径获取资源的顺序是一个从最底层 WebappClassLoader 开始(即 getResource 方法中调用的 Thread.currentThread().getContextClassLoader().getResource(resourceName)),再到最高层,后面逐级下降的顺序(后面就遵循双亲委派)。

而此时的路径为 template/../,首先会交给 WebappClassLoader 来获取资源,而 WebappClassLoader 负责的是项目中的用户类,获取资源的根目录为 WEB-INF/classes,这时候会调用 WebappClassLoader 的 findResource 来处理:

ResourceEntry entry = (ResourceEntry)this.resourceEntries.get(name);
if (entry == null) {
    if (this.securityManager != null) {
        PrivilegedAction<ResourceEntry> dp = new WebappClassLoader.PrivilegedFindResourceByName(name, name);
        entry = (ResourceEntry)AccessController.doPrivileged(dp);
    } else {
        entry = this.findResourceInternal(name, name);
    }
}

在缓存的 resourceEntries 哈希表未命中的情况下,会调用 findResourceInternal 进行下一步处理:

String fullPath = this.repositories[i] + path;
Object lookupResult = this.resources.lookup(fullPath);

这里的 repositories 只有唯一的 /WEB-INF/classes/,在跟资源路径拼接后会调用 lookup 开始获取资源,到了 FileDirContext 类的 file 方法中,经过 canonicalize0 方法的处理后会获得处理后的路径为 D:\Java1.8\struts2\target\struts2\WEB-INF\classes,然后就发生了一个问题:

String fileAbsPath = file.getAbsolutePath();
if (fileAbsPath.endsWith(".")) {
    fileAbsPath = fileAbsPath + "/";
}

String absPath = this.normalize(fileAbsPath);
canPath = this.normalize(canPath);
if (this.absoluteBase.length() < absPath.length() && this.absoluteBase.length() < canPath.length()) {
    absPath = absPath.substring(this.absoluteBase.length() + 1);
    if (absPath == null) {
        return null;
    }

    if (absPath.equals("")) {
        absPath = "/";
    }

    canPath = canPath.substring(this.absoluteBase.length() + 1);
    if (canPath.equals("")) {
        canPath = "/";
    }

    if (!canPath.equals(absPath)) {
        return null;
    }
}

但是这里的 absPath 的最后面是会有一个 / 的,这就导致了获取到的 file 永远是空的,也就是说 WebappClassLoader 这里是获取不到资源的,接下来就会委派给最高层的 ExtClassloader,可以阅读一下这篇文章,我们继续调试,给 ClassLoader 类的 getResource 方法和 URLClassPath 类的 findResource 方法下断点,会发现这是一个循环比较的过程:

public URL findResource(String var1, boolean var2) {
    int[] var4 = this.getLookupCache(var1);

    URLClassPath.Loader var3;
    for(int var5 = 0; (var3 = this.getNextLoader(var4, var5)) != null; ++var5) {
        URL var6 = var3.findResource(var1, var2);
        if (var6 != null) {
            return var6;
        }
    }

    return null;
}
...
URL findResource(String var1, boolean var2) {
    Resource var3 = this.getResource(var1, var2);
    return var3 != null ? var3.getURL() : null;
}
...
Resource getResource(String var1, boolean var2) {
    if (this.metaIndex != null && !this.metaIndex.mayContain(var1)) {
        return null;
    }
}
...
Resource getResource(String var1, boolean var2) {
    if (this.isClassOnlyJar && !var1.endsWith(".class")) {
        return false;
    } 
}

但是因为路径不是 .class 结尾的,所以最后都会失败。

ExtClassloader 无法获取资源,就会交给它的孩子 AppClassLoader, AppClassLoader 同样使用 JarLoader,也同样找不到,这两个 classloader 都是从 Jar 中获取资源的。

接下来交给 URLClassLoader,它可以从文件夹中获取资源,根目录在 tomcat 的 lib 目录,而在它的 getResource 方法中:

Resource getResource(final String var1, boolean var2) {
try {
    URL var4 = new URL(this.getBaseURL(), ".");
    final URL var3 = new URL(this.getBaseURL(), ParseUtil.encodePath(var1, false));
    if (!var3.getFile().startsWith(var4.getFile())) {
        return null;
    } else {
        if (var2) {
            URLClassPath.check(var3);
        }

        final File var5;
        if (var1.indexOf("..") != -1) {
            var5 = (new File(this.dir, var1.replace('/', File.separatorChar))).getCanonicalFile();
            if (!var5.getPath().startsWith(this.dir.getPath())) {
                return null;
            }
        } else {
            var5 = new File(this.dir, var1.replace('/', File.separatorChar));
        }

        return var5.exists() ? new Resource() {...}
    }
}

this.dir、this.getBaseURL() 就是 tomcat 的 lib 目录,调用 getCanonicalFile 之后,最后返回的就是 lib 这个目录的资源,所以我们就会读取到 lib 目录下的文件名。

同理,template/ 上向上跳一级,所以我们如果向上跳 4、5 级,同样可以访问到 tomcat 的 lib 目录,只不过这时触发的路径就不是 template 了,而是其他两个配置好的目录:

org/apache/struts2/static/
org/apache/struts2/interceptor/debugging/

继续思考,为什么回退两级会是 404?

首先,确定最下面的 WebappClassLoader 肯定也会因为前面说的路径匹配不上而没有东西,上面两级 JarLoader 的肯定是没有东西的,所以直接去看 URLClassLoader,上面两个 org/apache 的目录肯定是访问不到文件的,所以直接看 template,错误发生在这里:

URL var4 = new URL(this.getBaseURL(), ".");
final URL var3 = new URL(this.getBaseURL(), ParseUtil.encodePath(var1, false));
if (!var3.getFile().startsWith(var4.getFile())) {
    return null;
}

因为 template/ 往上跳了两级,所以这里的 var3 会跳到 lib 上一级的 tomcat 目录,跳出根目录导致没有东西。

剩下的跳 >5 次就比较简单了,6、7 次的时候,两个 4、5 级的目录加上 WEB-INF 和 classes 刚好 6、7 次,所以 WebappClassLoader 获取资源时刚好跳到根目录,即 IDEA 的编译目录 /D:/Java1.8/struts2/target/struts2,而用来切割然后比较的根目录 absoluteBase 为 D:\Java1.8\struts2\target\struts2,所以 canPath 会在切割完之后为空,而代码会加上一个 /,所以就跟 absPath 相同了,也就可以目录穿越访问到项目根目录了。


参考文章:

https://blog.csdn.net/how_interesting/article/details/80091472

https://xz.aliyun.com/t/7967


Web Java Struts2

本博客所有文章除特别声明外,均采用 CC BY-SA 3.0协议 。转载请注明出处!

struts2系列漏洞 S2-007
struts2系列漏洞 S2-003/S2-005