Java代码审计之图形验证码模块

  • A+

常见实现方式

  在服务端中随机生成一个指定位置的验证码,一般是四位或者六位。然后把该验证码保存到session中,再通过Java绘制类图以图片的形式输出该验证码。为了增加验证码的安全级别,可以输出图片的同时输出干扰线,最后在 用户提交数据的时,在服务器端将用户提交的验证码和session保存的验证码进行比较,若校验成功则进行相应的业务操作。
  例如下图是简单的一个图形验证码业务流程:

图片.png

  图片验证码的生成案例:

```java
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Font;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.geom.AffineTransform;
import java.awt.geom.Line2D;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.Random;
import javax.imageio.ImageIO;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

public class PictureCheckCode extends HttpServlet {
private static final long serialVersionUID = 1L;

public PictureCheckCode() {

super();

}

public void destroy() {

super.destroy();

}

public void init() throws ServletException {

super.init();

}

/该方法主要作用是获得随机生成的颜色/
public Color getRandColor(int s,int e){

Random random=new Random ();

if(s>255) s=255;

if(e>255) e=255;

int r,g,b;

r=s+random.nextInt(e-s); //随机生成RGB颜色中的r值

g=s+random.nextInt(e-s); //随机生成RGB颜色中的g值

b=s+random.nextInt(e-s); //随机生成RGB颜色中的b值

return new Color(r,g,b);

}

@Override

public void service(HttpServletRequest request, HttpServletResponse response)

throws ServletException, IOException {

//设置不缓存图片

response.setHeader("Pragma", "No-cache");

response.setHeader("Cache-Control", "No-cache");

response.setDateHeader("Expires", 0);

//指定生成的响应图片,一定不能缺少这句话,否则错误.

response.setContentType("image/jpeg");

int width=86,height=30; //指定生成验证码的宽度和高度

BufferedImage image=new BufferedImage(width,height,BufferedImage.TYPE_INT_RGB); //创建BufferedImage对象,其作用相当于一图片

Graphics g=image.getGraphics(); //创建Graphics对象,其作用相当于画笔

Graphics2D g2d=(Graphics2D)g; //创建Grapchics2D对象

Random random=new Random();

Font mfont=new Font("楷体",Font.BOLD,16); //定义字体样式

g.setColor(getRandColor(200,250));

g.fillRect(0, 0, width, height); //绘制背景

g.setFont(mfont); //设置字体

g.setColor(getRandColor(180,200));

//绘制100条颜色和位置全部为随机产生的线条,该线条为2f

for(int i=0;i<100;i++){

int x=random.nextInt(width-1);

int y=random.nextInt(height-1);

int x1=random.nextInt(6)+1;

int y1=random.nextInt(12)+1;

BasicStroke bs=new BasicStroke(2f,BasicStroke.CAP_BUTT,BasicStroke.JOIN_BEVEL); //定制线条样式

Line2D line=new Line2D.Double(x,y,x+x1,y+y1);

g2d.setStroke(bs);

g2d.draw(line); //绘制直线

}

//输出由英文,数字,和中文随机组成的验证文字,具体的组合方式根据生成随机数确定。

String sRand="";

String ctmp="";

int itmp=0;

//制定输出的验证码为四位

for(int i=0;i<4;i++){

switch(random.nextInt(3)){

case 1: //生成A-Z的字母

itmp=random.nextInt(26)+65;

ctmp=String.valueOf((char)itmp);

break;

default:

itmp=random.nextInt(10)+48;

ctmp=String.valueOf((char)itmp);

break;

}

sRand+=ctmp;

Color color=new Color(20+random.nextInt(110),20+random.nextInt(110),random.nextInt(110));

g.setColor(color);

//将生成的随机数进行随机缩放并旋转制定角度 PS.建议不要对文字进行缩放与旋转,因为这样图片可能不正常显示

/将文字旋转制定角度/

Graphics2D g2d_word=(Graphics2D)g;

AffineTransform trans=new AffineTransform();

trans.rotate((45)3.14/180,15i+8,7);

/缩放文字/

float scaleSize=random.nextFloat()+0.8f;

if(scaleSize>1f) scaleSize=1f;

trans.scale(scaleSize, scaleSize);

g2d_word.setTransform(trans);

g.drawString(ctmp, 15*i+18, 14);

}

HttpSession session=request.getSession(true);

session.setAttribute("randCheckCode", sRand); //绑定会话
g.dispose(); //释放g所占用的系统资源

ImageIO.write(image,"JPEG",response.getOutputStream()); //输出图片

}

}
```

常见漏洞场景及审计方法

验证码大小可控导致的拒绝服务攻击漏洞

  一般是验证码width、height值由前端控制,后端无大小检测,可以尝试生成多个超大数据包,如果同时并发请求的话会造成服务器CPU占用过高导致ddos。

  例如下面的案例,生成验证码后保存到session容器中与会话进行绑定,然后通过PicUtils方法生成图片,同时图片大小通过参数控制,若width、height前端可控且没有大小控制的话可能会存在ddos安全问题:

java
...
HttpSession session = request.getSession(false);
String captcha = PicUtils.drawImg_getCode();
session.setAttribute("captcha",captcha);
BufferedImage buffImg = PicUtils.generatePic(captcha,width,height)
...

验证码可复用

  因为验证码一般跟session容器进行绑定,在业务中例如登陆连带账号密码一起验证,如果验证码认证成功后(业务完成后),没有将session中的验证码字段及时清空,将会导致验证码首次认证成功之后仍可重复使用的问题。

  例如下面的案例:

java
HttpSession session=request.getSession(true);
//获取用户输入的图片的验证码
String checkcode=request.getParameter("checkCode");
//获取session中存储的验证码
String codeverify = (String)session.getAttribute("randCheckCode");
//获取用户名和密码
String username = request.getParameter("username");
String password = request.getParameter("password");
if(checkcode.equals("")||checkcode==null){
out.println("please input the checkcode");
}else{
if(!checkcode.equalsIgnoreCase(codeverify)){
out.println("checkCode fault");
}else{
//调用service层进行登陆验证
......
}
}

  根据上面的登陆的逻辑,当验证码为空时,提示请输入验证码。当验证码错误时,提示验证码错误。当验证码校验正确时,才进入账号密码校验环节。但是由于校验过程结束后没有清空session容器中的验证码,导致验证码可复用。也就是说,可以重复提交表单进行暴力破解操作。

  正确的做法是验证码在一次认证成功后,服务端应该及时将session中存储的验证码进行清除。

java
session.removeAttribute("randCheckCode");//防止用户重复提交表单

图形验证码可识别

  一般是检查以下内容:

  1. 背景元素的干扰,如背景色、背景字母等;
  2. 字符的字体进行扭曲、粘连;
  3. 使用公式、逻辑验证方法等作为验证码。例如四则运算、问答题等(注意撞库攻击);
  4. 图形验证码和使用者活动绑定,例如选择联系人头像,购买过的商品等作为验证码

验证码失效

  除了上面的场景,有时候一些设计缺陷可直接导致验证码失效。常见的有:

  • 置空cookie绕过

  在后端逻辑校验过程中并未对前端传递验证码参数为null进行相关的逻辑判断,直接删除验证码参数或置空值即可绕过。

  例如如下代码:

  当输入的验证码跟session容器中的一致即可进行登陆校验了,那么也就是说只要把session容器中的验证码置空就可以在不提交验证码的情况下进行登陆了,前面提到验证码的实现一般是结合会话在第一次请求后保存在session中。那么可以尝试删除cookie,然后此时服务端后重新分配一个新的session,然后把对应的cookie返回给客户端(Set-cookie操作),此时该会话并没有进行验证码请求操作,也就说此时的session中验证码的值为null,那么就可以绕过验证码机制了:

java
HttpSession session=request.getSession(true);
//获取用户输入的图片的验证码
String checkcode=request.getParameter("checkCode");
//获取session中存储的验证码
String codeverify = (String)session.getAttribute("randCheckCode");
//获取用户名和密码
String username = request.getParameter("username");
String password = request.getParameter("password");
if(!checkcode.equalsIgnoreCase(codeverify)){
out.println("checkCode fault");
}else{
//调用service层进行登陆验证
......
}

  所以说对于每一个需要进行业务操作的参数进行空值校验是一个好习惯,上述场景加入对未对未生成验证码的情况的逻辑判断:

java
session.getAttrubute("code")!=null

  当然对于用户提交的验证码字段进行空值校验也是十分有必要的。

  PS,这里不能直接判断cookie是否为null(因为可以随意设置一个不存在的cookie值,这样服务器端接受到该cookie后无法找到对应的session,也会重新分配一个session,然后进行Set-cookie操作)。

  • 校验先后顺序问题

  比较扯的一个场景,先校验账号密码,再校验验证码。虽然最后都会返回验证码错误,请重新请求。但是当账号密码校验成功后,已经把对应的用户凭证绑定当前会话了,跟暴力破解成功无异,直接访问业务接口即可操作业务了。

  例如如下代码:

java
HttpSession session =request.getSession(false);
//获取用户输入的图片的验证码
String checkcode=request.getParameter("checkCode");
//获取session中存储的验证码
String codeverify = (String)session.getAttribute("randCheckCode");
//获取用户名和密码
String username = request.getParameter("username");
String password = request.getParameter("password");
//调用service层进行登陆验证
if(user!=null){
session.setAttribute("user",user);
}
if(!checkcode.equalsIgnoreCase(codeverify)){
out.println("checkCode fault");
}

  使用图形验证码的任何操作,后台接收到操作请求之后都需先进行验证码正确性校验。

  • 其他

  还有类似验证码信息没有与相关信息校验凭证(例如账号密码)一同校验提交导致的绕过,步骤越多,越容易出问题,验证码校验成功后的标志没有绑定会话或者说在业务结束后没有及时清除同样也会带来暴力破解、批量提交等安全问题。
  在比如验证码直接前端生成校验了,这种的话直接查看后端的业务接口是否接受验证码参数即可快速定位了。

总结

  关键还是定位对应的图形验证码业务模块,一般可以直接结合登陆等业务进行快速定位。业务逻辑这块主要看开发的脑洞了。