一个反斜杠“/”,让你的系统破防!

4,928 阅读8分钟

系统防护变成纸老虎

请注意,下文中所有的代码示例我都省略了无关代码,仅仅保留关键代码。如果对代码详情有更多的兴趣,可以查阅相关源码。

我喜欢在文章结尾说一些关于程序员的题外话,帮助程序员成长,有兴趣可以看一看。

这个故事要从前两天公司发现一个漏洞说起。

我们都知道,在浏览网站时需要登录。当你获得相应的授权以后,才可以进行进一步操作。比如类似http://xxx/page/administrator这种接口,在未登录的情况下,访问会返回这种结果:

最近发现的漏洞就是,对于部署在jetty上的服务。通过加反斜杠可以绕过漏洞,即通过http://xxx//page/administrator可以正常返回数据(出于安全考虑,这里不展示正常的返回数据了),不需要鉴权!!!

为什么鉴权会被绕过

jetty在匹配filter时,使用了相对严格的匹配模式,对于/page/administrat路径,会命中/page/*的规则,从而被鉴权过滤器(比如SSOFilter)拦截进行鉴权。

然而,//page/administrat这种有两个反斜杠的路径,不属于上述命中规则,因此也不会被拦截,从而长驱直入。这就很恐怖!!

解决方法

方法一

直接将鉴权过滤器的匹配规则设置成/*,直接拦截所有访问。这样就不存在绕过的可能性了,这样简单粗暴,不用费脑子。

这个方法有点小问题,因为我们知道大公司里的服务,一般都会有 健康检查。监控系统会去访问健康检查路径,系统返回http返回码200 OK证明服务正常。监控系统访问健康检查路径是不会去带上鉴权信息的。

因此,你得在拦截器中单独排除健康检查路径,这就很不优雅。要是不排除健康检查的路径,就会一直因为鉴权失败而无法返回200 OK,导致监控系统认为你的服务一直运行不起来,然后发出警报。于是有了方法二。

方法二

在正常的访问中,是不会存在//XXX/YY这种连续出现多个反斜杠的非法请求的。所以,我在鉴权拦截器前边再加一个拦截器,这个拦截器直接过滤这种非法请求不就可以了?

public void doFilter(ServletRequest request,ServletResponse response, FilterChain filterChain)
{
  String  url= request.getRequestURL();
  if(!url.indexOf("//")<0)
  { //很显然,公司代码里当然没有这么写,不过意思差不多
    response.getWriter().write("别跟爷爷闹,乖乖的输入地址")
  }else{
    ....
  }
}

response.getWriter().write("别跟爷爷闹,乖乖的输入地址")才是精髓。

**你狂任你狂,我直接板砖破武术!**逻辑很简单,但是很有效。只要你格式不对,就压根不给你访问。有什么花招尽管使出来,你绕过来我算你赢!

不过你可能会问,url.indexOf("//")<0只能判断//,那///或者////等等这种呢?朋友,///里边不是有一个//嘛?肯定会检测出来噻!

要不要被扒裤子

上边我们确实解决问题了,安全隐患也暂时被解决了。但是你有没有仔细想过一个问题?

当黑客小哥哥的浏览器看到这句别跟爷爷闹,乖乖的输入地址的时候,我们的SSO到底生效了吗?或者说,他的访问请求到底是在绕过了SSOFilter之后,被我们的格式校验filter拦截。还是先被格式校验filter拦截,压根连SSOFilter摸都没摸到?

这中间是有质的区别的。前者是他扒了你的裤子,但是你以光速重新提上了。后者则是你系紧了腰带,让他随便扒也扒不下来,并用嘲讽的眼光揶揄他。两者虽然都暂时没有走光的风险,但明显后者安全感max!

那么,我们的系统到底是前者还是后者?**取决于filter的在系统中的加载顺序!**要谈起这个问题,就属于孩子没娘,说来话长了。我们今天好好掰扯一下这个问题。

Filter的前世今生

作用

web中的filter一般被称为过滤器,也可以叫拦截器。听名字就知道差不多知道它的作用。

实际上,Filter和servlet很类似,可以认为是servlet的另一种特殊形态。和servlet一样,也是对请求进行处理,只不过filter并不生成返回给用户的响应结果。

Filter在web应用中有举足轻重的作用,很多功能都需要靠Filter完成。比如常见的:鉴权、访问日志、编码解码、拦截特殊请求等等,都可以通过filter进行实现。

配置与实例

在web应用中配置一个Filter有两种方法,一种是通过web.xml,另一种的通过注解方式。

下边我们自定义一个Filter,这个Filter实现一个简单的功能:当有访问来时,我们的控制台输出有人访问啦这句话。通过这个例子来分别展示两种配置。

定义一个Filter

package quLunBianCheng;
public class DemoFilter implements Filter {

    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("容器启动时,会调用init方法对DemoFilter进行实例化哦");
    }

    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("有人来访问");
        chain.doFilter(request, response);
    }

    public void destroy() {
        System.out.println("DemoFilter要被销毁时,会先调用destroy方法");
    }
}


xml配置

<filter>
   <filter-name>DemoFilter</filter-name>
   <filter-class>quLunBianCheng.DemoFilter</filter-class>
 </filter>
 <filter-mapping>
    <filter-name>DemoFilter</filter-name>
    <url-pattern>/*</url-pattern>
 </filter-mapping>

这个配置我们应该很熟悉,总是能在web.xml中看到这种配置。 <url-pattern>/*</url-pattern> 意味所有访问都将被DemoFilter拦截。因此,每当有访问时,你的控制台上都会输出有人来访问啦

由此,你可以衍生出更加复杂的功能。比如,你可以统计你的网页被访问的次数,只需要稍微改写一下DemoFilter的doFilter方法即可。

public void doFilter(ServletRequest request,ServletResponse response, FilterChain chain) throws IOException, ServletException {
        System.out.println("有人来访问");
      	//访问次数+1	
      	Constant.visitCount++;
        System.out.println("你的网页已经被"+Constant.visitCount+"人访问过啦");  
        chain.doFilter(request, response);
    }
 

@WebFilter注解

注解形式有很多好处,比如看起来比较舒服、代码比较整洁、免去xml配置文件等等。但是,本质上和xml没有区别,只是换了一种方式而已。

注解方式中无需配置xml文件,只需要把DemoFilter做一个小小的改动就可以。

package quLunBianCheng;
@WebFilter(filterName = "DemoFilter",urlPatterns = "/*")
public class DemoFilter implements Filter {

    public void init(FilterConfig filterConfig) throws ServletException {
        System.out.println("容器启动时,会调用init方法对DemoFilter进行实例化哦");
    }
		//为了简洁起见,我这里吧dofilter和destory方法省略了,没有写出来。
}

只用加上@WebFilter(filterName = "DemoFilter",urlPatterns = "/*")就可以完成配置。

实际上,@WebFilter注解的可选配置参数还有很多,已经列在文章末尾—>@webFilter属性附录

过滤器虽然重要,但是你不要怕,实际上就这么点事儿。什么?你想知道Filter的初始化例程?这个下篇文章吧,这篇文章写不下了。因为下边还要讲挺多东西的,篇幅不太够了。

一个鉴权Filter的诞生

本文是以一个鉴权问题为开头的,那么我们接着讲鉴权。前边讲Filter就是为了引出鉴权Filter。

登录鉴权过滤器

实际上,鉴权filter本身就是一个Filter,没有什么神秘的。分分钟实现一个鉴权filter。

把上边的DemoFilter过滤器改一改,就可以变成登录鉴权浏览器。

package quLunBianCheng;
public class LoginFilter implements Filter {
    //省略非关键代码
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        //获得请求
        HttpServletRequest httpServletRequest = (HttpServletRequest) request;
        //拿到cookie
        Cookie[] cookies = request.getCookies();
      	//遍历cookie,找到用户名和密码
      	String username = "";
        String password = "";
      	if (cookies != null && cookies.length > 0) {
            for (Cookie cookie : cookies) {
                if (cookie.getName().equals("username")) {
                    username = cookie.getValue();
                } else if (cookie.getName().equals("password")) {
                    password = cookie.getValue();
                }
            }
        }
      	//如果第一次登录没有cookie或者用户名、密码不一致,则让他重新登录
      	if (username.equals("") || password.equals("")||(!username.equals("XXX")) ||(!password.equals("XXXXXXX")) {
          	//重定向,让他登录
          	response.sendRedirect("login.jsp");	  
          	return ;
        } else{
          //如果通过就可以放行了  
          chain.doFilter(request,response);
        }
    }
}

上述代码中的!username.equals("XXX")) ||(!password.equals("XXXXXXX")仅仅是一个简单示范,实际上真实项目中这个都是从数据库查询的,而不是XXXXXX

Filter的配置在这里就不细说了,和前边是一致的,你可以用web.xml或者@webFilter注解两种方式去配置(但是,…….请不要同时用两种方法……)

可以看出,实现一个简单的登录过滤器是非常简单的。但是真实的生产环境中,是不会这么做的,为啥?因为不安全啊!(**程序员小哥哥为了你的数据安全真的是煞费苦心,所以答应我不要把密码设置成aaa123或者123456789这种,可以吗?在线跪地磕头!!!! **)。

不瞎聊了,我们看看上边的做法为啥不安全。因为我们在cookie中直接存放了用户名和密码,cookie是存在用户浏览器中,很容易破解。用户很难有专业能力去保护cookie信息。cookie中包含大量的个人重要信息(不仅仅是密码,还有可能存放你在物理世界的一些信息)是很危险的行为。

怎么办?假如我们在cookie里放一个没啥意义的标识码,你每次请求带上这个标识码,服务器通过标识码寻找存在服务器中的用户信息(称为session),这个标识码就是sessionId。即便黑客拿到cookie,他也无法从cookie里直接解出你的个人信息,这样就相对安全多了(毕竟服务器抗攻击的能力远远强于用户侧,如果保护得当,黑客想从服务器中获取敏感信息的难度就大的多了)。

因此,LoginFilter经过一些改变,就可以变成下边的SSOFilter

ssoFilter

package quLunBianCheng;
public class SsoFilter implements Filter {
    public void init(FilterConfig filterConfig) throws ServletException {
      /*初始化方法  接收一个FilterConfig类型的参数 该参数是对Filter的一些配置*/
    }
    public void doFilter(ServletRequest request, ServletResponse response,FilterChain chain) throws IOException, ServletException {
       /*这里就不重复放代码了,用伪代码说一下把*/
        1:和上边一样,从Cookies中过滤出ssoId
        2:再拿着这个ssoId去服务器里查询出对应的登录名和密码
        3:如果登录名和密码匹配,就放行。如果不符合,就重新登录
    }
}

这样,我们就从一个漏洞开始,完整的讲述了Filter—>鉴权Filter->SSOFilter。并且每一步都给出了详细的例子,甚至包括配置信息。

但是这些就足够了吗?NO,还是不太Ok的。我在!!!一文中,我曾经讲过:做技术的人必须要学会刨根问底。

再进一步

这一小节中主要以不同的声明方式为引子,抛出更多关于过滤器的类,并试图详细解释它们的原理。

如果没有特殊说明。这一小节中提到的SsoFilter就是上小节中的SsoFilter

你有没有在web.xml中见过这样的配置?

<filter>
		<filter-name>SsoFilter</filter-name>
		<filter-class>org.springframework.web.filter.DelegatingFilterProxy</filter-class>
		<init-param>
			<param-name>targetFilterLifecycle</param-name>
			<param-value>true</param-value>
		</init-param>
	</filter>
<filter-mapping>
    <filter-name>SsoFilter</filter-name>
    <url-pattern>/*</url-pattern>
</filter-mapping>

或者这样的配置?

@Bean
public FilterRegistrationBean<Filter> LoginFilter() {
        FilterRegistrationBean<Filter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new SsoFilter());
        registration.addUrlPatterns("/*");
        registration.setName("SsoFilter");
        registration.setOrder(0);
        registration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD);
        return registration;
    }

亦或者这样的配置?

@Bean
public DelegatingFilterProxyRegistrationBean ssoFilterProxyRegistrationBean() {
    DelegatingFilterProxyRegistrationBean filterRegistration = new DelegatingFilterProxyRegistrationBean("ssoFilter");
    filterRegistration.addInitParameter("targetFilterLifecycle", "true");
    filterRegistration.addUrlPatterns("/*");
    filterRegistration.setDispatcherTypes(DispatcherType.REQUEST, DispatcherType.FORWARD);
    filterRegistration.setOrder(2);
    return filterRegistration;
}

大概率你是见过的,只是你没有注意或者关注为什么这么写而已。其实,这三种方式实现的功能和之前的ssoFilter一模一样,只不过是使用了代理而已

我好端端的,为啥非要去代理一下子?因为,**通过代理可以让我们自己定义的ssoFilter与容器完美融合,享用spring容器功能,用spring来管理Filter的生命周期,Filter中如果用到某些bean可以直接通过注入的方法获取。**此外还有很多的便利之处。所以,我们很有必要了解一下这些代理方法。

我们看到这三种方式中,出现了三个新的代理类DelegatingFilterProxyFilterRegistrationBeanDelegatingFilterProxyRegistrationBean

本来想在这篇文章把后边的也讲了,但是感觉篇幅太长了,所以打算分成两期。

我们下一篇讲这三个代理类的原理,并且回答前文提到的扒裤子的问题。

附录

健康检查

所谓的健康检查,就是controller有一个访问接口,一般路径都是xxx/alive。访问这个路径如果返回的HTTP响应码是200,则我们可以任务服务已经运行起来了。如果不能返回200,则这个系统现在肯定是有问题。

@webFilter属性附录

属性名类型描述
filterNameString指定过滤器的 name 属性
valueString[]该属性等价于 urlPatterns 属性。但是两者不应该同时使用
urlPatternsString[]指定一组过滤器的URL匹配模式。等价于 标签
servletNamesString[]指定过滤器将应用于哪些 servlet。取值是@webServlet中的name属性的取值,或者是web.xml中 取值
dispatcherTypersDispatcherType指定过滤器的转发模式,具体取值包括:ASYNC、ERROR、FORWARD、INCLUDE、REQUEST。
initParamsWebInitParam[]指定一组过滤器初始化参数,等价于标签
asyncSupportedboolean声明过滤器是否支持异步操作模式,等价于标签
descriptionString该过滤器的描述信息,等价于标签
displayNameString该过滤器的显示名,通常配合工具使用,等价于标签

实际上,任何一个注解的参数,我们都可以不必通过百度去搜索。因为,对于一个技术人员,获取第一手的资料是一项非常重要的能力。

你直接command+鼠标左键点击注解,你就会跳转到对应注解的定义。@webFilter的定义如下:

@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface WebFilter {

    String description() default "";
    
    String displayName() default "";
    
    WebInitParam[] initParams() default {};
    
    String filterName() default "";
    
    String smallIcon() default "";

    String largeIcon() default "";

    String[] servletNames() default {};
    
    String[] value() default {};

    String[] urlPatterns() default {};

    DispatcherType[] dispatcherTypes() default {DispatcherType.REQUEST};
    
    boolean asyncSupported() default false;
}

你可以清楚的看见有哪些参数,并且每一个都有清晰、明了的注释。自己动手,丰衣足食!

语录

语录:

如果一个人不会游泳,那么无论换哪一个游泳池,他都不会游。

解析:

看有些程序员总是抱怨自己没有好的机会,说自己维护着一些屎一样的代码,没有挑战,只有冗杂和毫无意义的事情。实际上,没有任何一件事事毫无意义的。如果你能从不同的角度去看待一个问题,用不同的心态去面对一些境遇,你就不会有这个抱怨。

给任何一个人一个好的机会,结果都不会太差。但那些真正超越别人的人,绝不是靠命运的垂青才脱颖而出的。那些抱怨机会不行的人,大概是不想付出太多努力,而又期望一夜暴富。工地上把墙面砌的最整齐的农民工,总是能获得包工头的赏识。把任何一件事做到极致,都不是一件毫无意义的事情。更何况看我这篇文章的人,处境和起点要比底层体力劳动人民好无数倍。