CSRF 原理以及 Struts2 令牌校验防御攻略解析
struts2 token 不仅能够有效防止表单重复提交,而且还可以进行
CSRF 验证。
CSRF 攻击原理如下图:
CSRF 攻击原理图
事实上,B 可能也是一个良性网站,只是被黑客 XSS 劫持了而已。用户实在冤枉啊:我没有上乱七八糟的网站,怎么还是中招了呢?
struts2 token 校验原理如图所示:
Struts2 token 验证原理图
对照 CSRF 攻击原理图,可以看到,虽然 a.jsp 将这个令牌值返回给浏览器,但是 B 是无法拿到这个令牌的具体值的,或者说 B 只能够通过去请求 A 才能拿到一个令牌,但这样就失去了伪造用户请求的目的,因为 session 不一致了。所以 b.action 也就不再处理这个伪造的请求,截断了 CSRF 的流程如下图所示:
Struts2 token截断了CSRF攻击原理图
了解了原理,那就干活吧。结果发现很多非 form 表单提交的请求都被成功拦截了,比如原有的 ajax 请求(js / jQuery / DWR)、页面的 href 请求都无法正常工作了——把这些请求的 action 的 token 验证去掉就能正常工作,但却这给黑客带来了可趁之机。
那么怎么样才可以让 form 表单之外的其他请求传递 struts2 token 呢?
我们继续来深入了解一下 struts2 token 验证的原理。
它调用了 org.apache.struts2.util.TokenHelper 的 validToken 方法,其源码:
它先调用自己的 getTokenName() 拿到 tokenName 值,然后根据这个值使用自己的 getToken(tokenName) 拿到客户端请求过来的 token 参数的值,最后跟 session 中保存的值进行比较。
查看 getTokenName() 源码:
TOKEN_NAME_FIELD 是 org.apache.struts2.util.TokenHelper 的静态变量,值为 "struts.token.name",getTokenName 方法的作用就是拿到客户端传过来的 struts.token.name 参数的值。validToken 拿到这个值以后,根据这个值继续调用 getToken,getToken 源码:
它根据 struts.token.name 参数的值,比如为 "token"(这个值可以在 jsp 的 token 标签中进行配置,比如 <s:token name="token"/>,默认值是 token),拿到客户端传来的 token 参数的值,最后返回给 validToken 方法。
使用 chrome 的开发者工具截取到的带有 token 验证的 struts action 印证了上面的说法:
截取到的struts.token.name的值
原理都了解了,接下来的事情似乎就顺理成章了。
$.ajax 调用支持 struts2 token:
页面 href 请求支持 struts2 token:
参考资料
CSRF 攻击原理如下图:
CSRF 攻击原理图
事实上,B 可能也是一个良性网站,只是被黑客 XSS 劫持了而已。用户实在冤枉啊:我没有上乱七八糟的网站,怎么还是中招了呢?
struts2 token 校验原理如图所示:
Struts2 token 验证原理图
对照 CSRF 攻击原理图,可以看到,虽然 a.jsp 将这个令牌值返回给浏览器,但是 B 是无法拿到这个令牌的具体值的,或者说 B 只能够通过去请求 A 才能拿到一个令牌,但这样就失去了伪造用户请求的目的,因为 session 不一致了。所以 b.action 也就不再处理这个伪造的请求,截断了 CSRF 的流程如下图所示:
Struts2 token截断了CSRF攻击原理图
了解了原理,那就干活吧。结果发现很多非 form 表单提交的请求都被成功拦截了,比如原有的 ajax 请求(js / jQuery / DWR)、页面的 href 请求都无法正常工作了——把这些请求的 action 的 token 验证去掉就能正常工作,但却这给黑客带来了可趁之机。
那么怎么样才可以让 form 表单之外的其他请求传递 struts2 token 呢?
我们继续来深入了解一下 struts2 token 验证的原理。
以最新版的 2.3.20 为例。我们在 struts.xml 对某 action 加 <interceptor-ref name="token"></interceptor-ref> 标签,事实上是配置了 org.apache.struts2.interceptor.TokenInterceptor 拦截器。查看其 doIntercept 源码:
protected String doIntercept(ActionInvocation invocation) throws Exception {
if (log.isDebugEnabled()) {
log.debug("Intercepting invocation to check for valid transaction token.");
}
//see WW-2902: we need to use the real HttpSession here, as opposed to the map
//that wraps the session, because a new wrap is created on every request
HttpSession session = ServletActionContext.getRequest().getSession(true);
synchronized (session) {
if (!TokenHelper.validToken()) {
return handleInvalidToken(invocation);
}
}
return handleValidToken(invocation);
}
它调用了 org.apache.struts2.util.TokenHelper 的 validToken 方法,其源码:
public static boolean validToken() {
String tokenName = getTokenName();
if (tokenName == null) {
if (LOG.isDebugEnabled()) {
LOG.debug("no token name found -> Invalid token ");
}
return false;
}
String token = getToken(tokenName);
if (token == null) {
if (LOG.isDebugEnabled()) {
LOG.debug("no token found for token name "+tokenName+" -> Invalid token ");
}
return false;
}
Map session = ActionContext.getContext().getSession();
String sessionToken = (String) session.get(tokenName);
if (!token.equals(sessionToken)) {
if (LOG.isWarnEnabled()) {
LOG.warn(LocalizedTextUtil.findText(TokenHelper.class, "struts.internal.invalid.token", ActionContext.getContext().getLocale(), "Form token {0} does not match the session token {1}.", new Object[]{token, sessionToken}));
}
return false;
}
// remove the token so it won't be used again
session.remove(tokenName);
return true;
}
它先调用自己的 getTokenName() 拿到 tokenName 值,然后根据这个值使用自己的 getToken(tokenName) 拿到客户端请求过来的 token 参数的值,最后跟 session 中保存的值进行比较。
查看 getTokenName() 源码:
public static String getTokenName() {
Map params = ActionContext.getContext().getParameters();
if (!params.containsKey(TOKEN_NAME_FIELD)) {
if (LOG.isWarnEnabled()) {
LOG.warn("Could not find token name in params.");
}
return null;
}
String[] tokenNames = (String[]) params.get(TOKEN_NAME_FIELD);
String tokenName;
if ((tokenNames == null) || (tokenNames.length < 1)) {
if (LOG.isWarnEnabled()) {
LOG.warn("Got a null or empty token name.");
}
return null;
}
tokenName = tokenNames[0];
return tokenName;
}
TOKEN_NAME_FIELD 是 org.apache.struts2.util.TokenHelper 的静态变量,值为 "struts.token.name",getTokenName 方法的作用就是拿到客户端传过来的 struts.token.name 参数的值。validToken 拿到这个值以后,根据这个值继续调用 getToken,getToken 源码:
public static String getToken(String tokenName) {
if (tokenName == null ) {
return null;
}
Map params = ActionContext.getContext().getParameters();
String[] tokens = (String[]) params.get(tokenName);
String token;
if ((tokens == null) || (tokens.length < 1)) {
if (LOG.isWarnEnabled()) {
LOG.warn("Could not find token mapped to token name " + tokenName);
}
return null;
}
token = tokens[0];
return token;
}
它根据 struts.token.name 参数的值,比如为 "token"(这个值可以在 jsp 的 token 标签中进行配置,比如 <s:token name="token"/>,默认值是 token),拿到客户端传来的 token 参数的值,最后返回给 validToken 方法。
使用 chrome 的开发者工具截取到的带有 token 验证的 struts action 印证了上面的说法:
截取到的struts.token.name的值
原理都了解了,接下来的事情似乎就顺理成章了。
$.ajax 调用支持 struts2 token:
<script>
var strutsToken = "<s:property value="#session['struts.tokens.token']" />";
var token = {
"struts.token.name": "token",
"token": strutsToken
};
$.ajax({
url: '/endpoint',
data: token,
dataType: 'jsonp',
cache: true,
success: function() { console.log('success'); },
error: function() { console.log('failure'); }
});
</script>
页面 href 请求支持 struts2 token:
<td>
<a href="findSigleDetail.action?TransId=${value.transid}&struts.token.name=token&token=
<s:property value="#session['struts.tokens.token']" />" title="Edit Meta"><img src="frame/image/pencil.png"
alt="Edit Meta" />查看详细</a>
</td>
参考资料