图形验证码(CAPTCHA),是Completely Automated Public Turing Test to Tell Computers and Humans Apart (全自动区分计算机和人类的图灵测试)的简称。其本质是一种区分用户是计算机还是人的公共全自动程序。可以有效的防止某些特定程序以暴力方式不断进行登录尝试。验证码的不断发展其实是随着其破解功能的逐步强大而跟着演进的,这是一个攻防博弈的精彩世界。
我们首先从手写几个简单的验证码开始,拉开验证码技术的面纱。
首先用SpringBoot搭建一个简单的Web应用,有一个登录页面,也带好了简单的登录功能。目前这样的登录页面,对于同一个用户,每次登录都只是输入相同的用户名和密码,没有任何变数。我们就可以尝试给他添加一点变数,就是图形验证码功能。
首先在后台增加一个生成图形验证码的工具:
package com.roy.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Random;
/**
* @author :楼兰
* @date :Created in 2021/1/14
* @description:
**/
public class ImageUtil {
private Logger logger = LoggerFactory.getLogger(ImageUtil.class);
private ByteArrayInputStream image;//图像
private String str;//验证码
private int width = 352;
private int height = 46;
private ImageUtil() {
init();//初始化属性
}
public static ImageUtil Instance() {
return new ImageUtil();
}
/*
* 取得验证码图片
*/
public ByteArrayInputStream getImage() {
return this.image;
}
/*
* 取得图片的验证码
*/
public String getString() {
return this.str;
}
private void init() {
this.str = "";
// 在内存中创建图象
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取图形上下文
Graphics g = image.getGraphics();
// 生成随机类
Random random = new Random();
// 设定背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
// 设定字体
g.setFont(new Font("Times New Roman", Font.PLAIN, 18));
// 随机产生155条干扰线,使图象中的认证码不易被其它程序探测到
//g.setColor(getRandColor(160, 200));
//for (int i = 0; i < 155; i++) {
// int x = random.nextInt(width);
// int y = random.nextInt(height);
// int xl = random.nextInt(12);
// int yl = random.nextInt(12);
// g.drawLine(x, y, x + xl, y + yl);
//}
// 取随机产生的认证码(6位数字)
String sRand = "";
for (int i = 0; i < 6; i++) {
String rand = String.valueOf(random.nextInt(10));
sRand += rand;
// 将认证码显示到图象中
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
// 调用函数出来的颜色相同,可能是因为种子太接近,所以只能直接生成
g.drawString(rand, (width/6) * i + 6, 46);
this.str += rand;/* 赋值验证码 */
}
// 图象生效
g.dispose();
ByteArrayInputStream input = null;
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
ImageOutputStream imageOut = ImageIO.createImageOutputStream(output);
ImageIO.write(image, "JPEG", imageOut);
imageOut.close();
input = new ByteArrayInputStream(output.toByteArray());
} catch (Exception e) {
System.out.println("验证码图片产生出现错误:" + e.toString());
}
this.image = input;/* 赋值图像 */
}
/*
* 给定范围获得随机颜色
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) fc = 255;
if (bc > 255) bc = 255;
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
然后在后台LoginController中添加一个生成验证码的方式,同时在登录端口增加一个简单的检查验证码的逻辑:
package com.roy.controller;
import com.roy.util.ImageUtil;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.PrintWriter;
/**
* @author :楼兰
* @date :Created in 2021/1/14
* @description:
**/
@RestController
@RequestMapping("/common/")
public class LoginController {
@PostMapping("/login")
public Object login(String verifyCode,HttpServletRequest request){
String sessionVerifyCode = request.getSession().getAttribute("verifyCode").toString();
if(sessionVerifyCode.equals(verifyCode)){
return "login";
}else{
return null;
}
}
@GetMapping("/verifyCode")
public void generateImg(HttpServletRequest request,HttpServletResponse response) throws IOException {
response.setContentType("image/jpeg");
ImageUtil imageUtil = ImageUtil.Instance();
ByteArrayInputStream image = imageUtil.getImage();
request.getSession().setAttribute("verifyCode",imageUtil.getString());
byte[] bytes = new byte[1024];
try(ServletOutputStream out = response.getOutputStream()){
while(image.read(bytes)!=-1){
out.write(bytes);
}
}
}
}
然后在前端就可以增加一个img标签将验证码显示出来
<img src="/common/verifyCode" onclick="changeValidateCode(this)">
<script>
function changeValidateCode(obj) {
//获取当前的时间作为参数,无具体意义
var timenow = new Date().getTime();
//每次请求需要一个不同的参数,否则可能会返回同样的验证码
//这和浏览器的缓存机制有关系,也可以把页面设置为不缓存,这样就不用这个参数了。
obj.src="/common/verifyCode?d="+timenow;
}
script>
这样就完成了一个简单的图形验证码,并且点击图片可以刷新出新的验证码图片。
后续也可以在登录时增加对验证码的判断。需要输入正确的图形验证码才能登录成功。
现在我们来思考下这个验证码的作用。这个验证码对于正常用户,似乎只是让登录变得更麻烦了一点,而并没有起到什么实质性的作用。其实他最大的作用是让一些不友好的计算机程序无法正常登录。例如一些爬虫程序,可以在骗取用户的用户名和密码后,就可以用机器脚本模拟用户输入用户名和密码等这些页面操作,继而大规模爬取用户登录后的一些业务敏感信息。而验证码的作用就是让程序无法自动识别图形中的数字,从而阻止爬虫程序进行登录操作。
我们这个简单的图形验证码已经可以一定程度上保护登录操作了,爬虫程序在进行登录时,是不知道要输入什么样的验证码的。但是这样的验证码有什么问题呢?接下来做个小实验来尝试用机器的方式破解这些验证码。
这里我们尝试用一个开源的tess4j框架来对验证码图片进行OCR光学识别。
首先,将生成的验证码图片保存到本地D:\verifyCode目录。我们多保存几张,用来测试破解的成功率。
net.sourceforge.tess4j
tess4j
4.5.4
然后需要到tess4j的官方git仓库上下载各种语言的训练数据。下载地址: https://github.com/tesseract-ocr/tessdata
其中,chi_sim.traineddata就是中文的训练数据,而eng.traineddata是英文的训练数据。我们把这两个文件下载下来放到D:\tessdata目录下。
然后就可以编写一个简单的程序来测试下之前保存的验证码的安全程度。
public class Tess4jDemo {
public static void main(String[] args) throws TesseractException {
ITesseract instance = new Tesseract();
instance.setDatapath("D:\\tessdata");
instance.setLanguage("chi_sim");
File imageLocation = new File("D:\\verifyCode");
for(File image: imageLocation.listFiles()){
System.out.println(image.getName()+">>>"+instance.doOCR(image));
}
}
}
看一下实验的结果:
verifyCode.jpg>>>0 7 0 9 7 6
verifyCode1.jpg>>>9 7 7 4 3 Z
verifyCode10.jpg>>>0 4 3 1 5 5
verifyCode2.jpg>>>6 0 8 3 Z 6
verifyCode3.jpg>>>3 Z 0 5 Z Z
verifyCode4.jpg>>>3 Z 0 5 Z Z
verifyCode5.jpg>>>3 Z 0 5 Z Z
verifyCode6.jpg>>>5 0 6 9 0 0
verifyCode7.jpg>>>4 1 5 7 8 0
verifyCode8.jpg>>>9 8 9 1 1 4
verifyCode9.jpg>>>0 4 3 1 5 5
有没有看出问题来? 这次简单的实验中,除了数字2的识别不太准备,其他数字基本上全部识别正确。这意味着,我们这个简单的验证码基本防不住爬虫程序的破解,爬虫程序只需要稍微尝试几次就能绕过我们手写的这个验证码。
理解了这个问题之后,再回头看看我们之前的验证码生成工具,其中注释了的画155条随机干扰线的代码,就能够理解他作用了把。就是希望让验证码图片变得更复杂,在不影响真人之别的前提下,降低破解程序的成功率。我们在网上看到的各种奇形怪状的验证码,也正是出于同样的目的。
但是从刚才的简单实验中也能看到,tess4j这样的程序对于不同语言的识别准确率,都是通过训练集来提升的,这也意味着不管你的验证码变得多么复杂难看,只要有一定的规律,tess4j就可以用机器学习的方式,把他作为一种新的语言来识别,从而提高识别成功率。实际上,验证码识别也是机器学习与深度学习技术的一个非常热门的研究领域。这是个动态的攻防领域,所以验证码也需要有其他更多的技术来增加破解难度。
为了提高验证码的安全性,将原始的图形验证码改成表达式验证码就是一个很常用的手段。其实本质也就是将展现出来的图形与保存到session中的验证码文字进行区分。例如图形展示一个随机的计算式,而将计算式的结果保存到session中来进行验证。
所以我们只需要对ImageUtil做一下修改。
package com.roy.util;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.imageio.ImageIO;
import javax.imageio.stream.ImageOutputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.Random;
/**
* @author :楼兰
* @date :Created in 2021/1/14
* @description:
**/
public class ImageUtil {
private Logger logger = LoggerFactory.getLogger(ImageUtil.class);
private ByteArrayInputStream image;//图像
private String str;//验证码
private int width = 352;
private int height = 46;
private ImageUtil() {
init();//初始化属性
}
public static ImageUtil Instance() {
return new ImageUtil();
}
/*
* 取得验证码图片
*/
public ByteArrayInputStream getImage() {
return this.image;
}
/*
* 取得图片的验证码
*/
public String getString() {
return this.str;
}
private void init() {
this.str = "";
// 在内存中创建图象
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
// 获取图形上下文
Graphics g = image.getGraphics();
// 生成随机类
Random random = new Random();
// 设定背景色
g.setColor(getRandColor(200, 250));
g.fillRect(0, 0, width, height);
// 设定字体
g.setFont(new Font("Times New Roman", Font.PLAIN, 18));
// 随机产生155条干扰线,使图象中的认证码不易被其它程序探测到
g.setColor(getRandColor(160, 200));
for (int i = 0; i < 155; i++) {
int x = random.nextInt(width);
int y = random.nextInt(height);
int xl = random.nextInt(12);
int yl = random.nextInt(12);
g.drawLine(x, y, x + xl, y + yl);
}
//页面上画一个计算表达式
int num1 = random.nextInt(10);
int num2 = random.nextInt(10);
g.setColor(new Color(20 + random.nextInt(110), 20 + random.nextInt(110), 20 + random.nextInt(110)));
g.drawString(String.valueOf(num1),(width/5)*0+6,36);
g.drawString("+",(width/5)*1+6,36);
g.drawString(String.valueOf(num2),(width/5)*2+6,36);
g.drawString("=",(width/5)*3+6,36);
g.drawString("?",(width/5)*4+6,36);
//验证码保存计算结果
this.str = ""+(num1+num2);
// 图象生效
g.dispose();
ByteArrayInputStream input = null;
ByteArrayOutputStream output = new ByteArrayOutputStream();
try {
ImageOutputStream imageOut = ImageIO.createImageOutputStream(output);
ImageIO.write(image, "JPEG", imageOut);
imageOut.close();
input = new ByteArrayInputStream(output.toByteArray());
} catch (Exception e) {
System.out.println("验证码图片产生出现错误:" + e.toString());
}
this.image = input;/* 赋值图像 */
}
/*
* 给定范围获得随机颜色
*/
private Color getRandColor(int fc, int bc) {
Random random = new Random();
if (fc > 255) fc = 255;
if (bc > 255) bc = 255;
int r = fc + random.nextInt(bc - fc);
int g = fc + random.nextInt(bc - fc);
int b = fc + random.nextInt(bc - fc);
return new Color(r, g, b);
}
}
这样就可以实现一个简单的表达式验证码:
这样,我们在页面上画出来的是一个计算表达式,而实际存到Session中的只是一个计算的结果。在后续登录判断时,就可以要求用户输入正确结果才能通过验证。
这样我们手写实现了自己的表达式验证码,安全性得到了一定程度的提高。
之前提到过,验证码技术是一个不断对抗升级的发展过程,接下来就介绍一些互联网上常用的开源验证码,带大家对验证码的技术环境有个大体的了解。以下这些组件也都在Demo工程中集成了进来。
1、jcaptcha http://jcaptcha.sourceforge.net/
这是一个java领域比较火的验证码框架,支持文字验证码、图片验证码以及语音验证码等多种形式。
2、Happy-Captcha https://gitee.com/ramostear/Happy-Captcha
一款实用非常简单的java验证码软件包。 支持图片和动画两种验证码风格,以及数字、中文、表达式等多种验证码类型。
类似的还有 https://gitee.com/whvse/EasyCaptcha
3、kcaptcha https://gitee.com/baomidou/kaptcha-spring-boot-starter
kcaptcha是Google开源的一款图形验证码框架,列出的这个是基于kcaptcha做出的springboot集成插件。
以上几款都是互联网上一些开源的图形验证码。这一类验证码其实跟我们之前手写的两个验证码属于同一类,都是通过在前端组织更复杂,更难以辨认的图形来加大破解难度。但是理论上,这一类图形验证码都可以通过我们试验过的OCR技术来进行破解,因为,本质上,我们人也是通过光学识别来判断图形中的内容的,只是人的行为比目前的技术要高级很多罢了。
下面介绍两款复杂点的行为验证码。这类验证码需要用户在网页上进行人机交互来完成验证。
4、滑块式验证码: https://gitee.com/LongbowEnterprise/SliderCaptcha
这个验证码可以实现滑块验证码,并且对用户的轨迹也有一些简单的判断逻辑。
5、AJ-Captcha: https://gitee.com/anji-plus/captcha?_from=gitee_search
这个验证码可以实现滑块验证码和点选汉字验证码。
这些行为验证码,很明显就无法通过OCR光学识别来破解了,还需要加入大量的人机交互操作。而这些人机交互操作,目前阶段也是可以通过类似于Selenium这样的组件来进行模拟的,例如点击鼠标、拖动滑块、松开鼠标,甚至输入文字等都可以模拟。因此,在安全性更高的场景下,还需要有更强大的验证码出现。
以下介绍几个比较有名的商业验证码。这些商业验证码通常都是通过在前端定义更高级的人机交互操作,来达到更难被计算机程序模拟破解的目的。互联网的很多商业场景中,经常会采用一种或者多种验证码整合的方式来提高验证码安全性。
极验验证码 https://www.geetest.com/
这是最为有名的一个专门做验证码的互联网产品,其中最有特色的是他这种点选式的验证码。只需要简单的点选中间的圆点就可以判断是人为操作还是机器模拟。这种验证码方式也是由极验最早推出,并开始逐渐推广到了其他商业验证码中。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5FLmS7Tu-1610854783623)(img/5.png)]
具体的实现机制无从获取,官方的说法是获取鼠标在页面上的移动轨迹,利用机器学习来判断操作对象是人还是计算机程序。不过以前曾经尝试过破解这个验证码,确实很难成功。而目前,越来越多的互联网公司都会引入这种验证码,再结合其他行为式验证码来形成组合策略。
网易网盾 https://dun.163.com/product/captcha
顶象科技 https://www.dingxiang-inc.com/business/captcha
腾讯天御验证码 https://cloud.tencent.com/product/captcha
以上讨论到的这些验证码,都是同步的人机交互验证码,通过定义更复杂的人机交互操作来达到提高破解难度的目的。而随着互联网逐渐走向多渠道联通,引入其他交互渠道来进行异步的人机交互,也成为一种非常好的验证码方式。例如目前常用的短信验证码、邮箱验证码、语音验证码等等的方式。
之前提到过,验证码技术其实是一个攻防博弈的动态发展的技术,因此,随着验证码发展得越来越安全,相应的破解技术也跟着不断发展,随之而来的,是双方的资源成本越来越高。
例如对于互联网应用来说,商业验证码、短信验证码等这些验证码,如果应用的业务流程控制不好,很容易被羊毛党、黑客等人利用,造成成本极大浪费。而网上逐渐出现的各种奇葩验证码,人眼难辨,也让很多正常用户叫苦不迭。
对于破解方来说,随着机器学习以及人工打码等技术的不断发展,可选择的技术手段也越来越多。验证码技术,也在破解方的大量资源投入下,变得越来越鸡肋。并且,很多互联网应用逐渐复杂的验证方式,安全性提高的同时,也提高了很多正常用户的使用门槛,成为了各种电信诈骗的温床。验证码技术逐渐开始偏离了最初的初衷。
而未来的验证码技术,一方面,会通过引入更多的验证元素来进一步提高验证码的安全性,例如刷脸、刷指纹、语音交互、点选你购买过的商品 等。另一方面,通过对行为式验证码的研究逐渐深入,可以从传统的面向结果的验证,转化成面向过程的行为验证,并逐渐减少人工参与,降低关键信息被劫持的风险,形成更多对用户无感知的验证方式。例如分析用户鼠标轨迹、按键频率、使用习惯等。
总之,验证码技术,是一个所有人都将亲身参与的精彩世界。
详细视频详解参见B站视频:https://www.bilibili.com/video/BV1eV411q76a
示例代码会上传到码云上。https://gitee.com/tearwind/CaptchaDemo