验证码是目前互联网上很是常见也是很是重要的一个事物,充当着不少系统的 防火墙 功能,可是随时OCR技术的发展,验证码暴露出来的安全问题也愈来愈严峻。本文介绍了一套字符验证码识别的完整流程,对于验证码安全和OCR识别技术都有必定的借鉴意义。html
关键词:安全,字符图片,验证码识别,OCR,Python,SVM,PILpython
本文研究所用素材来自于某旧Web框架的网站 彻底对外公开 的公共图片资源。git
本文只作了该网站对外公开的公共图片资源进行了爬取, 并未越权 作任何多余操做。程序员
本文在书写相关报告的时候已经 隐去 漏洞网站的身份信息。github
本文做者 已经通知 网站相关人员此系统漏洞,并积极向新系统转移。web
本报告的主要目的也仅是用于 OCR交流学习 和引发你们对 验证安全的警觉 。算法
关于验证码的非技术部分的介绍,能够参考之前写的一篇科普类的文章:编程
http://www.cnblogs.com/beer/p/4996833.html
里面对验证码的种类,使用场景,做用,主要的识别技术等等进行了讲解,然而并无涉及到任何技术内容。本章内容则做为它的 技术补充 来给出相应的识别的解决方案,让读者对验证码的功能及安全性问题有更深入的认识。安全
要达到本文的目的,只须要简单的编程知识便可,由于如今的机器学习领域的蓬勃发展,已经有不少封装好的开源解决方案来进行机器学习。普通程序员已经不须要了解复杂的数学原理,便可以实现对这些工具的应用了。
主要开发环境:
python SDK版本
图片处理库
开源的svm机器学习库
关于环境的安装,不是本文的重点,故略去。
通常状况下,对于字符型验证码的识别流程以下:
因为本文是以初级的学习研究目的为主,要求 “有表明性,但又不会太难” ,因此就直接在网上找个比较有表明性的简单的字符型验证码(感受像在找漏洞同样)。
最后在一个比较旧的网站(估计是几十年前的网站框架)找到了这个验证码图片。
原始图:
放大清晰图:
此图片能知足要求,仔细观察其具备以下特色。
有利识别的特色 :
以上就是本文所说的此验证码简单的重要缘由,后续代码实现中会用到
不利识别的特色 :
这虽然是不利特色,可是这个干扰门槛过低,只须要简单的方法就能够除去
因为在作训练的时候,须要大量的素材,因此不可能用手工的方式一张张在浏览器中保存,故建议写个自动化下载的程序。
主要步骤以下:
这些都是一些IT基本技能,本文就再也不详细展开了。
关于网络请求和文件保存的代码,以下:
def downloads_pic(**kwargs): pic_name = kwargs.get('pic_name', None) url = 'http://xxxx/rand_code_captcha/' res = requests.get(url, stream=True) with open(pic_path + pic_name+'.bmp', 'wb') as f: for chunk in res.iter_content(chunk_size=1024): if chunk: # filter out keep-alive new chunks f.write(chunk) f.flush() f.close()
循环执行N次,便可保存N张验证素材了。
下面是收集的几十张素材库保存到本地文件的效果图:
虽然目前的机器学习算法已经至关先进了,可是为了减小后面训练时的复杂度,同时增长识别率,颇有必要对图片进行预处理,使其对机器识别更友好。
针对以上原始素材的处理步骤以下:
主要步骤以下:
image = Image.open(img_path) imgry = image.convert('L') # 转化为灰度图 table = get_bin_table() out = imgry.point(table, '1')
上面引用到的二值函数的定义以下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
def
get_bin_table(threshold
=
140
):
"""
获取灰度转二值的映射table
:param threshold:
:return:
"""
table
=
[]
for
i
in
range
(
256
):
if
i < threshold:
table.append(
0
)
else
:
table.append(
1
)
return
table
|
由PIL转化后变成二值图片:0表示黑色,1表示白色。二值化后带噪点的 6937 的像素点输出后以下图:
1111000111111000111111100001111100000011 1110111011110111011111011110111100110111 1001110011110111101011011010101101110111 1101111111110110101111110101111111101111 1101000111110111001111110011111111101111 1100111011111000001111111001011111011111 1101110001111111101011010110111111011111 1101111011111111101111011110111111011111 1101111011110111001111011110111111011100 1110000111111000011101100001110111011111
若是你是近视眼,而后离屏幕远一点,能够隐约看到 6937 的骨架了。
在转化为二值图片后,就须要清除噪点。本文选择的素材比较简单,大部分噪点也是最简单的那种 孤立点,因此能够经过检测这些孤立点就能移除大量的噪点。
关于如何去除更复杂的噪点甚至干扰线和色块,有比较成熟的算法: 洪水填充法 Flood Fill ,后面有兴趣的时间能够继续研究一下。
本文为了问题简单化,干脆就用一种简单的本身想的 简单办法 来解决掉这个问题:
下面将详细介绍关于具体的算法原理。
将全部的像素点以下图分红三大类
种类点示意图以下:
固然,因为基准点在计算区域的方向不一样,A类点和B类点还会有细分:
而后这些细分点将成为后续坐标获取的准则。
主要算法的python实现以下:
def sum_9_region(img, x, y): """ 9邻域框,以当前点为中心的田字框,黑点个数 :param x: :param y: :return: """ # todo 判断图片的长宽度下限 cur_pixel = img.getpixel((x, y)) # 当前像素点的值 width = img.width height = img.height if cur_pixel == 1: # 若是当前点为白色区域,则不统计邻域值 return 0 if y == 0: # 第一行 if x == 0: # 左上顶点,4邻域 # 中心点旁边3个点 sum = cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 4 - sum elif x == width - 1: # 右上顶点 sum = cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) return 4 - sum else: # 最上非顶点,6邻域 sum = img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 6 - sum elif y == height - 1: # 最下面一行 if x == 0: # 左下顶点 # 中心点旁边3个点 sum = cur_pixel \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x, y - 1)) return 4 - sum elif x == width - 1: # 右下顶点 sum = cur_pixel \ + img.getpixel((x, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y - 1)) return 4 - sum else: # 最下非顶点,6邻域 sum = cur_pixel \ + img.getpixel((x - 1, y)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x, y - 1)) \ + img.getpixel((x - 1, y - 1)) \ + img.getpixel((x + 1, y - 1)) return 6 - sum else: # y不在边界 if x == 0: # 左边非顶点 sum = img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 6 - sum elif x == width - 1: # 右边非顶点 # print('%s,%s' % (x, y)) sum = img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x - 1, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) return 6 - sum else: # 具有9领域条件的 sum = img.getpixel((x - 1, y - 1)) \ + img.getpixel((x - 1, y)) \ + img.getpixel((x - 1, y + 1)) \ + img.getpixel((x, y - 1)) \ + cur_pixel \ + img.getpixel((x, y + 1)) \ + img.getpixel((x + 1, y - 1)) \ + img.getpixel((x + 1, y)) \ + img.getpixel((x + 1, y + 1)) return 9 - sum
Tips:这个地方是至关考验人的细心和耐心程度了,这个地方的工做量仍是蛮大的,花了半个晚上的时间才完成的。
计算好每一个像素点的周边像素黑点(注意:PIL转化的图片黑点的值为0)个数后,只须要筛选出个数为 1或者2 的点的坐标即为 孤立点 。这个判断方法可能不太准确,可是基本上可以知足本文的需求了。
通过预处理后的图片以下所示:
对比文章开头的原始图片,那些 孤立点 都被移除掉,相对比较 干净 的验证码图片已经生成。
因为字符型 验证码图片 本质就能够看着是由一系列的 单个字符图片 拼接而成,为了简化研究对象,咱们也能够将这些图片分解到 原子级 ,即: 只包含单个字符的图片。
因而,咱们的研究对象由 “N种字串的组合对象” 变成 “10种阿拉伯数字” 的处理,极大的简化和减小了处理对象。
现实生活中的字符验证码的产生千奇百怪,有各类扭曲和变形。关于字符分割的算法,也没有很通用的方式。这个算法也是须要开发人员仔细研究所要识别的字符图片的特色来制定的。
固然,本文所选的研究对象尽可能简化了这个步骤的难度,下文将慢慢进行介绍。
使用图像编辑软件(PhoneShop或者其它)打开验证码图片,放大到像素级别,观察其它一些参数特色:
能够获得以下参数:
这样就能够很容易就定位到每一个字符在整个图片中占据的像素区域,而后就能够进行分割了,具体代码以下:
def get_crop_imgs(img): """ 按照图片的特色,进行切割,这个要根据具体的验证码来进行工做. # 见原理图 :param img: :return: """ child_img_list = [] for i in range(4): x = 2 + i * (6 + 4) # 见原理图 y = 0 child_img = img.crop((x, y, x + 6, y + 10)) child_img_list.append(child_img) return child_img_list
而后就能获得被切割的 原子级 的图片元素了:
基于本部分的内容的讨论,相信你们已经了解到了,若是验证码的干扰(扭曲,噪点,干扰色块,干扰线……)作得不够强的话,能够获得以下两个结论:
4位字符和40000位字符的验证码区别不大
纯数字。分类数为10
数字和区分大小写的字母组合。分类数为62
在没有造成 指数级或者几何级 的难度增长,而只是 线性有限级 增长计算量时,意义不太大。
本文所选择的研究对象自己尺寸就是统一状态:6*10的规格,因此此部分不须要额外处理。可是一些进行了扭曲和缩放的验证码,则此部分也会是一个图像处理的难点。
在前面的环节,已经完成了对单个图片的处理和分割了。后面就开始进行 识别模型 的训练了。
整个训练过程以下:
本文在训练阶段从新下载了同一模式的4数字的验证图片总计:3000张。而后对这3000张图片进行处理和切割,获得12000张原子级图片。
在这12000张图片中删除一些会影响训练和识别的强干扰的干扰素材,切割后的效果图以下:
因为本文使用的这种识别方法中,机器在最开始是不具有任何 数字的观念的。因此须要人为的对素材进行标识,告诉 机器什么样的图片的内容是 1……。
这个过程叫作 “标记”。
具体打标签的方法是:
为0~9每一个数字创建一个目录,目录名称为相应数字(至关于标签)
人为断定 图片内容,并将图片拖到指定数字目录中
通常状况下,标记的素材越多,那么训练出的模型的分辨能力和预测能力越强。例如本文中,标记素材为十多张的时候,对新的测试图片识别率基本为零,可是到达100张时,则能够达到近乎100%的识别率
对于切割后的单个字符图片,像素级放大图以下:
从宏观上看,不一样的数字图片的本质就是将黑色按照必定规则填充在相应的像素点上,因此这些特征都是最后围绕像素点进行。
字符图片 宽6个像素,高10个像素 ,理论上能够最简单粗暴地能够定义出60个特征:60个像素点上面的像素值。可是显然这样高维度必然会形成过大的计算量,能够适当的降维。
经过查阅相应的文献 [2],给出另一种简单粗暴的特征定义:
最后获得16维的一组特征,实现代码以下:
def get_feature(img): """ 获取指定图片的特征值, 1. 按照每排的像素点,高度为10,则有10个维度,而后为6列,总共16个维度 :param img_path: :return:一个维度为10(高度)的列表 """ width, height = img.size pixel_cnt_list = [] height = 10 for y in range(height): pix_cnt_x = 0 for x in range(width): if img.getpixel((x, y)) == 0: # 黑色点 pix_cnt_x += 1 pixel_cnt_list.append(pix_cnt_x) for x in range(width): pix_cnt_y = 0 for y in range(height): if img.getpixel((x, y)) == 0: # 黑色点 pix_cnt_y += 1 pixel_cnt_list.append(pix_cnt_y) return pixel_cnt_list
而后就将图片素材特征化,按照 libSVM 指定的格式生成一组带特征值和标记值的向量文件。内容示例以下:
说明以下:
对此文件格式有兴趣的同窗,能够到 libSVM 官网搜索更多的资料。
到这个阶段后,因为本文直接使用的是开源的 libSVM 方案,属于应用了,因此此处内容就比较简单的。只须要输入特征文件,而后输出模型文件便可。
能够搜索到不少相关中文资料 [1] 。
主要代码以下:
def train_svm_model(): """ 训练并生成model文件 :return: """ y, x = svm_read_problem(svm_root + '/train_pix_feature_xy.txt') model = svm_train(y, x) svm_save_model(model_path, model)
备注:生成的模型文件名称为 svm_model_file
训练生成模型后,须要使用 训练集 以外的全新的标记后的图片做为 测试集 来对模型进行测试。
本文中的测试实验以下:
在早期训练集样本只有每字符十几张图的时候,虽然对训练集样本有很好的区分度,可是对于新样本测试集基本没区分能力,识别基本是错误的。逐渐增长标记为8的训练集的样本后状况有了比较好的改观:
以数字8的这种模型强化方法,继续强化对数字0~9中的其它数字的模型训练,最后能够达到对全部的数字的图片的识别率达到近乎 100%。在本文示例中基本上每一个数字的训练集在100张左右时,就能够达到100%的识别率了。
模型测试代码以下:
def svm_model_test(): """ 使用测试集测试模型 :return: """ yt, xt = svm_read_problem(svm_root + '/last_test_pix_xy_new.txt') model = svm_load_model(model_path) p_label, p_acc, p_val = svm_predict(yt, xt, model)#p_label即为识别的结果 cnt = 0 for item in p_label: print('%d' % item, end=',') cnt += 1 if cnt % 8 == 0: print('')
至此,验证的识别工做算是完满结束。
在前面的环节,验证码识别 的相关工具集都准备好了。而后对指定的网络上的动态验证码造成持续不断地识别,还须要另外写一点代码来组织这个流程,以造成稳定的黑盒的验证码识别接口。
主要步骤以下:
而后本文中,请求某网络验证码的http接口,得到验证码图片,识别出结果,以此结果做为名称保存此验证图片。效果以下:
显然,已经达到几乎 100% 的识别率了。
在本算法没有作任何优化的状况下,在目前主流配置的PC机上运行此程序,能够实现200ms识别一个(很大的耗时来自网络请求的阻塞)。
后期经过优化的方式能够达到更好的效率。
软件层次优化
预计能够达到1s识别10到100个验证码的样子。
硬件层次优化
基本上,10台4核心机器同时请求,保守估计效率能够提高到1s识别1万个验证码。
若是验证码被识别出来后,会有什么安全隐患呢?
在你们经过上一小节对识别效率有了认识以后,再提到这样的场景,你们会有新的见解了吧:
暂先无论后面有没有手续上的黑幕,在一切手续合法的状况下,只要经过技术手段识别掉了验证码,再经过计算机强大的计算力和自动化能力,将大量资源抢到少数黄牛手中在技术是彻底可行的。
因此从此你们抢不到票不爽的时候,能够继续骂12306,可是不要骂它有黑幕了,而是骂他们IT技术不精吧。
关于一个验证码失效,即至关于没有验证码的系统,再没有其它风控策略的状况下,那么这个系统对于代码程序来就就彻底如入无人之境。
具体请参考:
http://www.cnblogs.com/beer/p/4814587.html
经过上面的例子,你们能够看到:
因此,这一块虽然小,可是安全问题不能忽视。
本文介绍的实际上是一项简单的OCR技术实现。有一些很好同时也颇有积极进步意义的应用场景:
这些场景有具备和本文所研究素材很类似的特色:
因此若是拍照时原始数据采集比较规范的状况下,识别起来应该难度也不大。
本文只是选取了一个比较典型的并且比较简单的验证码的识别做为示例,可是基本上能表述出一个识别此类验证码的完整流程,能够供你们交流学习。
因为目前全球的IT技术实力良莠不齐,如今不少旧的IT系统里面都存在一些旧的页面框架,里面使用的验证码也是至关古老,对于当下的一些识别技术来讲,彻底不堪一击。好比,我看到一些在校大学生就直接拿本身学校的 教务系统 的验证码来 开刀练习 的。
最后,本文特地提出以下倡议:
应该尽快认识到事情的严重性,赶忙升级本身的系统,或者将这一块业务交付给专门的安全公司
[1] | LibSVM for Python 使用 http://www.cnblogs.com/Finley/p/5329417.html |
[2] | 基于SVM的手写体阿拉伯数字识别.张鸽,陈书开.长沙理工大学计算机通信工程学院.2005 |
我估计这样 长文 绝大部分人是不会有兴趣所有看完的。但为了它的内容完整性,仍是决定先以整篇的方式发表出来吧。
后面有空再拆分连载吧。
源码开源共享:
https://github.com/zhengwh/captcha-svm