论坛首页 Java企业应用论坛

一个使用线程局部存储(ThreadLocal)技术导致用户会话信息泄露案例的剖析

浏览 13630 次
精华帖 (0) :: 良好帖 (4) :: 新手帖 (0) :: 隐藏帖 (0)
作者 正文
   发表时间:2008-01-16  

一个使用线程局部存储(ThreadLocal)技术导致用户会话信息泄露案例的剖析

我们的系统是一个B/S架构的WEB系统,采用的是类似struts的基于action的WEB框架,近期系统上线后碰到了一个用户会话信息泄露的问题,虽然问题最终于半天后得到了解决,但对此问题的剖析有利于我们更深地理解与多线程并发相关的线程局部存储(ThreadLocal)技术,故特撰此文与大家共飨。

线程局部存储(ThreadLocal)技术是多线程技术中用于解决并发问题的一个最轻量级且使用起来最简单的技术。其原理是将一块内存与线程关联,每个线程访问的的变量都存在于本线程的局部存储区中,因此多个线程间访问相同的变量名时不会产生并发问题。对应就到java中,类ThreadLocal就是JVM用来实现线程局部存储的。

我们磁到的问题是这样的,正常情况下用户登录后系统首页会显示用户的账户等信息,但某用户登录后发现其首页显示的却是其他人的信息。当其再次刷新首页后,页面信息显示却又恢复了正常。发生这样的问题后,我们在测试环境下进行了测试,分别使用两个用户在不同的浏览器中登录系统,并同时刷新首页,此时问题被复现了,而且发生此问题的机率还比较高,粗略估计每10笔就有一两笔发生。另外测试中还发现一个现象比较值得注意,就是此问题并非是并发情况下才会发生,当一个用户未发送任何交易时,另一用户多次刷新页面后还有可能会显示前一用户的信息。

经验告诉我们,如果一个问题有时发生有时不发生,而且发生的机率不是很高,那么该问题很有可能与多线程并发有关。问题发生后我们首先想到是否是程序中的交易处理类未考虑多线程并发呢?最后的结果表明这个问题确实与多线程并发有关,但却并非是交易类未考虑并发而导致的,事实上系统中的所有交易类都是线程安全的(类似Webwork中的Action类),根本不需考虑多线程并发的问题。为了更好地让大家思考这个问题,下面先描述一下系统中交易的基本处理流程,如下图:
----> EncodingFilter, UserSessionFilter ---> MainServlet ---> Transaction ---> JSP

用户发起的交易首先经过一组过滤器进行交易的通用处理,其中包括字符集转换过滤器、用户会话处理过滤器等,其中用户会话过滤器实现了基于线程局部存储技术的用户会话访问(后面会详细描述)。过滤器处理后所有交易全部交由一个主控的Sevlet根据交易名进行交易的转发。具体交易处理类调用相关领域对象实现交易处理,并为JSP页面准备展示所需数据。

在上述过程中,因为交易类需要频繁访问用户会话信息,比如获取当前用户的权限信息、获取当前用户的帐户信息等,为了减少参数传递,系统中的RequestContext类实现了获取上述对象的便捷方法。以下是该类的部分方法:
public class RequestContext {
	private static ThreadLocal context = new ThreadLocal() {
		 protected synchronized Object initialValue() {
			return new RequestContext();
		}
	};
	
	public static RequestContext get() {
		return (RequestContext) context.get();
	}
	
	public User getUser();
	public UserSession getSession();
	public String getIP();
	...
}

各交易类使用如下方式获取所需信息:
User user = RequestContext.get().getUser();
UserSession session = RequestContext.get().getIP();
String ip = RequestContext.get().getIP();
...

系统通过用户会话过滤器拦截所有经由主控Servlet处理的交易,在交易处理前将用户信息注入到一个RequestContext的实例中,然后将该实例与当前线程绑定,这样随后的交易类就可以便利地访问用户会话信息了,相关代码如下:
public class UserSessionFilter implements Filter {
	...
	public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
		...
		UserSession session = getSession();
		User user = getUserFromSession(session);
		RequestContext.get().clear();
		RequestContext.get().setSession(session);
		RequestContext.get().setUser(user);
		...
		chain.doFilter(request, response);
		...
	}
	...
}

以上对系统的交易处理流程作了一个大致的介绍,那么回到文章开头的问题中,是什么导致了用户会话信息的泄露呢?也就是说是什么导致了另一个用户可以访问其它用户的RequestContext中的数据呢?

答案相当简单,就是因为这个首页交易只是一个纯粹的JSP页面,该交易并未经过用户会话过滤器的处理。有人可能会问,既然该页面并未经过滤器处理,那么该JSP页面对应的处理线程的RequestContext中就不应该有任何用户信息,这样JSP页面上就应该不显示任何内容才对,为什么页面上反而会显示出其它人的用户信息呢?要解答这个问题就要先了解应用服务器是如何使用线程技术处理用户请求的。应用服务器收到一个用户请求后,总是分配一个独立的线程对该请求进行处理,考虑到频繁创建和销毁线程的开销太大,一般应用服务器都会有一个高效的线程池系统来回收已完成处理的请求线程,也就是说当某个请求被处理完后,相应线程并不会被销毁,而是被返回到线程池中以再次响应其它请求。这样一说,大家是不是就明白问题原因所在了呢?

是的,当某个用户提交访问页面的请求时,应用服务器会从线程池中取得一个空闲线程以处理该请求,如果此时分配的线程是曾经响应过其它用户请求的线程时,该线程的局部存储中就还保留有其它用户的用户信息,因为系统中所有交易都经由会话过滤器处理过,所以当执行流程转到交易类时,线程的局部存储中已经有了正确的用户信息,此时并不会产生任何问题。而一旦所访问的交易没有经过会话过滤器处理时,页面上就出现仍然存留于线程局部存储中的其它用户的信息了。

一旦问题的原因分析清楚了,要解决就很容易。


通过以上的剖析,你是否对线程局部存储(ThreadLocal)、线程池等技术有了更深的理解呢?欢迎大家多谈谈自己的看法。



   发表时间:2008-01-16  
的确 因为容器是用线程池来管理请求线程。所以用threadlocal来管理user session是不合适的。
0 请登录后投票
   发表时间:2008-01-16  
难道线程被放回线程池的时候不清除之前的线程局部变量?晕。。。。应该算线程池的一个BUG吧
0 请登录后投票
   发表时间:2008-01-16  
jomper 写道
的确 因为容器是用线程池来管理请求线程。所以用threadlocal来管理user session是不合适的。

不是这样的,threadlocal来管理用户是非常合适的,可以说是一个典型的正确用法。
fch0402 写道
难道线程被放回线程池的时候不清除之前的线程局部变量?晕。。。。应该算线程池的一个BUG吧

这个跟线程池无关,还是应用代码写得有问题

我认为这个这个bug是对做权限的人对threadlocal的不甚了解导致的,因为在一次请求结束的时候他应该把线程中的用户数据清空的。如果有了这个操作那么什么问题都没有了。acegi中就是这么做的:
finally {
            // This is the only place in this class where SecurityContextHolder.getContext() is called
            SecurityContext contextAfterChainExecution = SecurityContextHolder.getContext();

            // Crucial removal of SecurityContextHolder contents - do this before anything else.
            SecurityContextHolder.clearContext();

            request.removeAttribute(FILTER_APPLIED);
·········
}
0 请登录后投票
   发表时间:2008-01-16  
同意楼上的,明显是代码没写好嘛,我也是这么使用的啊,但只要filter记得清除了,根本不会产生相应的问题嘛
0 请登录后投票
   发表时间:2008-01-16  
fch0402 写道
难道线程被放回线程池的时候不清除之前的线程局部变量?晕。。。。应该算线程池的一个BUG吧

线程池是web容器的,那个pool怎么可能知道被你取出的thread 会用来做什么,更不可能去清楚那个不知哪里来的ThreadLocal里的线程副本.web容器的线程池是无辜的.
0 请登录后投票
   发表时间:2008-01-16  
ahuaxuan 写道

我认为这个这个bug是对做权限的人对threadlocal的不甚了解导致的,因为在一次请求结束的时候他应该把线程中的用户数据清空的。如果有了这个操作那么什么问题都没有了。acegi中就是这么做的


一次请求是指的,一次request发生?
那么request发生的次数似乎是会非常频繁,那么每次都从session里取出来 并存为线程副本,为何不从session里直接取呢?

似乎在一个线程引起的一个生命周期相对较长的会话里,使用ThreadLocal来保存线程副本才是比较合适的,短期会话里线程副本刚刚被创建,就马上被迫消亡,并没有起到在同一个线程里长期使用,避免并发和重复创建复杂对象作用。
0 请登录后投票
   发表时间:2008-01-16  
jomper 写道
ahuaxuan 写道

我认为这个这个bug是对做权限的人对threadlocal的不甚了解导致的,因为在一次请求结束的时候他应该把线程中的用户数据清空的。如果有了这个操作那么什么问题都没有了。acegi中就是这么做的


一次请求是指的,一次request发生?
那么request发生的次数似乎是会非常频繁,那么每次都从session里取出来 并存为线程副本,为何不从session里直接取呢?

似乎在一个线程引起的一个生命周期相对较长的会话里,使用ThreadLocal来保存线程副本才是比较合适的,短期会话里线程副本刚刚被创建,就马上被迫消亡,并没有起到在同一个线程里长期使用,避免并发和重复创建复杂对象作用。


第一:放在线程里的好处是不需要参数传递,否则你就需要四处传递你的session了,
第二:如果我的信息不在session里,比如说在memcached里,难道每次我要取session的时候都去读一下memcached吗,当然是放在线程中最合适了
1 请登录后投票
   发表时间:2008-01-16  
ahuaxuan 写道

这个跟线程池无关,还是应用代码写得有问题

我认为这个这个bug是对做权限的人对threadlocal的不甚了解导致的,因为在一次请求结束的时候他应该把线程中的用户数据清空的。如果有了这个操作那么什么问题都没有了。

ahuaxuan同学说的非常准确,我在分析这个问题的过程中也一直存有两个疑问:
1、UserSessionFilter能否在请求处理完成后清除RequestContext?
代码如下所示:
public class UserSessionFilter implements Filter {
	...
	public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
		...
		UserSession session = getSession();
		User user = getUserFromSession(session);
		// RequestContext.get().clear(); // 以前是在这里清除
		RequestContext.get().setSession(session);
		RequestContext.get().setUser(user);
		...
		try {
			chain.doFilter(request, response);
		}
		finally {
			RequestContext.get().clear(); // 能否改为在这里清除?
		}
		...
	}
	...
}


2、UserSessionFilter能否改为拦截所有请求,而不仅限于MainServlet?
原来UserSessionFilter的配置是这样的:
	<filter-mapping>
		<filter-name>UserSessionFilter</filter-name>
		<servlet-name>MainServlet</servlet-name> <!-- 这里能否去掉?改为拦截"/*"? -->
		<!-- 
		<url-pattern>/*</url-pattern>
		-->
	</filter-mapping>


上面两个两种修改理论上也应该可以成立,但现在不敢确定是否会引起其它问题,故目前的修改方案是,所有的JSP页面不允许直接访问,必须通过MainServlet来访问。

之所以不敢确定,主要是对filter的拦截机制了解得还不是非常透彻,现在还不太清楚filter对forword和include的页面是如何做拦截的,不过这与目前讨论的问题已经没有太大关系了。


0 请登录后投票
   发表时间:2008-01-16  
楼上的第一种写法是正确的,试试就知道,没必要去拦截所有请求
0 请登录后投票
论坛首页 Java企业应用版

跳转论坛:
Global site tag (gtag.js) - Google Analytics