0x00 背景介紹
全自動區分計算機和人類的圖靈測試(英語:Completely Automated Public Turing test to tell Computers and Humans Apart,簡稱CAPTCHA),俗稱驗證碼。CAPTCHA這個詞最早是在2002年由卡內基梅隆大學的路易斯·馮·安、Manuel Blum、Nicholas J.Hopper以及IBM的John Langford所提出。CAPTCHA是一種區分用戶是計算機或人類的公共全自動程序,在CAPTCHA測試中,作為服務器的計算機會自動生成一個問題 讓用戶來解答。這個問題可以由計算機生成並評判,但是必須只有人類才能解答。因為計算機無法解答CAPTCHA的問題,所以回答出問題的用戶就可以被認為 是人類。
但是由於這個測試是由計算機來考人類,而不是像標准圖靈測試中那樣由人類來考計算機,所以更確切的講CAPTCHA是一種反向圖靈測試。[ 1 ]
0x01 常見驗證碼分類
文本驗證碼
文本驗證碼常以問答形式出現,如:給出問題要求用戶答案,給出古詩上舉要求用戶寫出下句等等。
因為所有的驗證碼問題和答案都要事先在數據庫中存好,所以這類驗證碼數量有限。攻擊者可以先將問題庫中的所有問題先爬取下來並准備相應的答案庫,破解時只需利用正則表達式將驗證問題提取出來,然后從答案庫中找到相應答案即可。
靜態圖驗證碼
靜態圖驗證碼是目前應用最廣的一類驗證碼,這類驗證碼要求用戶輸入驗證碼圖片上所顯示的文字或數字,通過扭曲、變形、干擾等方法避免被光學字符識別(OCR, Optical Character Recognition)之類的電腦程序自動辨識出圖片上的文字和數字。
但是由於許多驗證碼的設計者對驗證碼的意義理解的不到位,並且缺乏相關安全知識和經驗,所以目前在用的很多驗證碼都是可以被輕松攻破的。
動態圖驗證碼
動態圖驗證碼看似更為復雜,但是實際上動態驗證碼提供了更大的信息冗余,冗余越大,提供的信息就越多,因此也越容易被識別。例如,在某一幀原本粘連嚴重的兩字字符很能在另一幀中就比較好的分離開了。
語音驗證碼
許多開發者考慮到部分視覺障礙者,提供了語音驗證碼的功能,通過播放語音,讓用戶輸入聽到的內容來完成驗證。圖片驗證碼的識別主要是基於圖像處理技術,而語音驗證碼的識別主要是基於音頻處理,但是他們在識別的基本原理上是相同的。
短信驗證碼
隨着手機的普及,現在很多網站、應用開始使用短信驗證碼。服務器將驗證碼發送到用戶預留的手機號中,然后要求用戶輸入收到的驗證碼內容。
短信驗證碼的設計目的與上述三種驗證碼稍有不同,它不僅區分用戶是人類還是計算機計算機,它還主要用於驗證是否是用戶本人操作。但是由於部分開發人員的安全意識不足,這類驗證碼也可能被輕易地攻破。
0x02 驗證碼識別原理與過程
驗證碼識別主要分成三部分:預處理,字符分割,字符識別。下面以靜態圖驗證碼(后面將簡稱為:圖像驗證碼)為例來具體介紹識別原理。
預處理
預處理主要是將驗證碼圖片進行色度空間轉換、去除干擾、降噪、旋轉等操作,為字符分割的時候提供一個質量較好的圖片。
色度空間轉換
在預處理是常用到色度空間轉換,其中最主要的一種色度空間的轉換就是二值化。二值化目的是將前景(主要為有效信息,即驗證碼信息)與背景(多為干擾信息)分離,盡最大程度講有效信息提取出來,降低色彩空間維度,降低復雜度。
常用的方法:閾值法
統計一張圖片(彩色圖需轉成256色灰度圖)的灰度直方圖后可以看到該圖片在各灰度級上的像素分布數量。以下圖的驗證碼為例,我們可以看到最左邊 (即純黑色)與右側其他灰度級像素的分布有明顯一段隔開的區域,而圖中純黑色區域正好是有效信息(即驗證碼)。因此我們可以在該段隔開的區域里設一個閾 值,像素值大於閾值的置為白色,小於像素值的置為黑色。
下圖為通過上述辦法二值化后的結果,背景已完全被去除,而有效信息被完整的保留了下來。
但是有時當前景與背景像素的灰度值交織在一起時,我們則很難通過閾值法提取出有效信息。以下面這張驗證碼為例,我們可以從其灰度直方圖中看到所有像素點幾乎都聚集在了一起。
我們將閾值設在峰值左側嘗試二值化,可以從結果看出,這時有效信息非但沒有被提取出來,反而帶入了更強的干擾。對於此類驗證碼我們則需要在二值化之前先去除干擾。
代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
def
Binarized(Picture):
Pixels
=
Picture.load()
(Width, Height)
=
Picture.size
Threshold
=
80
# 閾值
for
i
in
xrange
(Width):
for
j
in
xrange
(Height):
if
Pixels[i, j] > Threshold:
# 大於閾值的置為背景色,否則置為前景色(文字的顏色)
Pixels[i, j]
=
BACKCOLOR
else
:
Pixels[i, j]
=
TEXTCOLOR
return
Picture
|
去除干擾
上述實驗已證明對於一些干擾較大的驗證碼我們需要先對其進行去干擾處理。去干擾的具體方法需要根據給定的驗證碼做有針對性的設計。 以某銀行驗證碼為例,仔細觀察可以發現驗證碼部分筆畫寬度相對較寬,而干擾線寬度僅為1像素。針對此特性我設計了一種分離有效信息和干擾信息的算法。
具體算法過程如下:
將驗證碼轉成256色灰度圖像后,用一個33的窗口以此覆蓋圖像中的每一個像素點,然后將窗口中9個點的像素值進行排序后取中值Vmid,比較Vmid與33窗口中中心像素的值。如果二者差值大於預設的閾值,則判斷該點顏色接近於白色還是黑色,若接近白色則將該點置為白色(255),若接近於黑色則置為黑色(0)。重復三次左右即可得到一個基本穩定的結果。
通過對比可以看出處理后的驗證碼區域灰度已被加深成黑色,與干擾線和背景的顏色已經明顯區分開。從處理后的灰度直方圖可以看出,像素點已主要集中在黑色(0)和白色(255)兩個灰度級,這時在用閾值法二值化即可得到一個比較令人滿意的結果了。
代碼如下:
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
|
def
Enhance(Picture):
'''分離有效信息和干擾信息'''
Pixels
=
Picture.load()
Result
=
Picture.copy()
ResultPixels
=
Result.load()
(Width, Height)
=
Picture.size
xx
=
[
1
,
0
,
-
1
,
0
,
1
,
-
1
,
1
,
-
1
]
yy
=
[
0
,
1
,
0
,
-
1
,
-
1
,
1
,
1
,
-
1
]
Threshold
=
50
Window
=
[]
for
i
in
xrange
(Width):
for
j
in
xrange
(Height):
Window
=
[i, j]
for
k
in
xrange
(
8
):
# 取3*3窗口中像素值存在Window中
if
0
<
=
i
+
xx[k] < Width
and
0
<
=
j
+
yy[k] < Height:
Window.append((i
+
xx[k], j
+
yy[k]))
Window.sort()
(x, y)
=
Window[
len
(Window)
/
2
]
if
(
abs
(Pixels[x, y]
-
Pixels[i, j]) < Threshold):
# 若差值小於閾值則進行“強化”
if
Pixels[i, j] <
255
-
Pixels[i,j]:
# 若該點接近黑色則將其置為黑色(0),否則置為白色(255)
ResultPixels[i, j]
=
0
else
:
ResultPixels[i, j]
=
255
else
:
ResultPixels[i, j]
=
Pixels[i, j]
return
Result
|
降噪
雖然上面結果的質量已經足以用於識別了,但我們仍然可以看到圖中存在明顯的噪聲,我們還可以通過降噪將其質量進一步提高。
降噪的主要目的是去除圖像中的噪聲,降噪方法有方法有很多如:平滑、低通濾波等……這里介紹一種相對簡單的方法——平滑降噪。具體方法是通過統計每 個像素點周圍像素值的個數來判斷將改點置為和值。如果一個點周圍白色點的個數大於某一閾值則將改點置為白色,反之亦然。通過平滑降噪已經可以將剩下的噪聲 點全部去除了。
這里需要注意的是對二值圖像進行降噪時應注意強度,當驗證碼筆畫較細時,降噪強度過大可能會破壞驗證碼本身的信息,這可能會影響到后面的識別效果。
代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
def
Smooth(Picture):
'''平滑降噪'''
Pixels
=
Picture.load()
(Width, Height)
=
Picture.size
xx
=
[
1
,
0
,
-
1
,
0
]
yy
=
[
0
,
1
,
0
,
-
1
]
for
i
in
xrange
(Width):
for
j
in
xrange
(Height):
if
Pixels[i, j] !
=
BACKCOLOR:
Count
=
0
for
k
in
xrange
(
4
):
try
:
if
Pixels[i
+
xx[k], j
+
yy[k]]
=
=
BACKCOLOR:
Count
+
=
1
except
IndexError:
# 忽略訪問越界的情況
pass
if
Count >
3
:
Pixels[i, j]
=
BACKCOLOR
return
Picture
|
字符分割
得到經過預處理的圖片后需要將每個字符單獨分隔出來,這里簡單介紹幾種字符分隔的方法。
投影法
投影法是根據圖片在投影方向上的像素個數進行分割的。
統計之前經過預處理圖像在豎直方向上的像素個數可以看到每兩個字符之間的像素個數有明顯斷開的情況。因此,我們在這些斷開處進行分隔即可。
投影法對於處理字符在投影方向上分布比較開的情況有比較好的效果,但是如果遇到當兩個字符在有影方向上有交集的情況則可能將兩個字符誤判成一個字符。
連通區域法
如果兩個點相鄰切顏色相同,則稱這兩個點是連通的。從一個點開始,所有與它直接或簡介連通的點集即為一個連通區域。 連通區域法是從一個點開始找其連通區域,然后將每一個連通區域分割成一個塊。
這樣每個字符都將作為一個連通區域沒分割出來。下圖中每一種顏色是一個連通區域。
連通區域法可以很好解決兩個字符雖然在有影方向上有交集可是沒有粘連的情況,但是如果兩個字符粘連在一起的話連通區域法也會將兩個字符誤判成一個。
對粘連字符的處理
如果對於上述情況我們可以通過最大字符寬度來判斷連個字符是否發生粘連。我們可以先統計一些字符,記下最大字符寬度,當用連通區域法分隔出的字符寬度大於最大字符寬度時,我們則認為這是粘連字符。
這里介紹兩種處理粘連字符的方法:
1. 根據平均字符寬度找極小值點分割字符
我們同樣先統計一些字符,記下平均字符寬度,當遇到兩個字符粘連時,從平均字符寬度處向兩側找豎直方向上有效像素個數的極小值點,然后從極小值點進行分割。
這種方法雖然在一定程度上可以解決粘連字符的問題,但是可能會破壞部分字符,這樣可能對之后的識別造成干擾。
2. 滴水算法
未解決上述問題提出了滴水算法。滴水算法的過程是從圖片頂部開始向下走,向水滴滴落一樣沿着字符輪廓下滑,當滴到輪廓凹處滲入筆畫,穿過筆畫后繼續滴落,最終水滴所經過的軌跡就構成了字符的分割路徑。[ 2 ]
從上圖可以看出粘連字符較好的被分割開並且在最大程度上保護了每一個字符的原貌。
代碼如下:
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
|
def
SplitCharacter(Block):
'''根據平均字符寬度找極小值點分割字符'''
Pixels
=
Block.load()
(Width, Height)
=
Block.size
MaxWidth
=
20
# 最大字符寬度
MeanWidth
=
14
# 平均字符寬度
if
Width < MaxWidth:
# 若小於最大字符寬度則認為是單個字符
return
[Block]
Blocks
=
[]
PixelCount
=
[]
for
i
in
xrange
(Width):
# 統計豎直方向像素個數
Count
=
0
for
j
in
xrange
(Height):
if
Pixels[i, j]
=
=
TEXTCOLOR:
Count
+
=
1
PixelCount.append(Count)
for
i
in
xrange
(Width):
# 從平均字符寬度處向兩側找極小值點,從極小值點處進行分割
if
MeanWidth
-
i >
0
:
if
PixelCount[MeanWidth
-
i
-
1
] > PixelCount[MeanWidth
-
i] < PixelCount[MeanWidth
-
i
+
1
]:
Blocks.append(Block.crop((
0
,
0
, MeanWidth
-
i
+
1
, Height)))
Blocks
+
=
SplitCharacter(Block.crop((MeanWidth
-
i
+
1
,
0
, Width, Height)))
break
if
MeanWidth
+
i < Width
-
1
:
if
PixelCount[MeanWidth
+
i
-
1
] > PixelCount[MeanWidth
+
i] < PixelCount[MeanWidth
+
i
+
1
]:
Blocks.append(Block.crop((
0
,
0
, MeanWidth
+
i
+
1
, Height)))
Blocks
+
=
SplitCharacter(Block.crop((MeanWidth
+
i
+
1
,
0
, Width, Height)))
break
return
Blocks
#!python
def
SplitPicture(Picture):
'''用連通區域法初步分隔'''
Pixels
=
Picture.load()
(Width, Height)
=
Picture.size
xx
=
[
0
,
1
,
0
,
-
1
,
1
,
1
,
-
1
,
-
1
]
yy
=
[
1
,
0
,
-
1
,
0
,
1
,
-
1
,
1
,
-
1
]
Blocks
=
[]
for
i
in
xrange
(Width):
for
j
in
xrange
(Height):
if
Pixels[i, j]
=
=
BACKCOLOR:
continue
Pixels[i, j]
=
TEMPCOLOR
MaxX
=
0
MaxY
=
0
MinX
=
Width
MinY
=
Height
# BFS算法從找(i, j)點所在的連通區域
Points
=
[(i, j)]
for
(x, y)
in
Points:
for
k
in
xrange
(
8
):
if
0
<
=
x
+
xx[k] < Width
and
0
<
=
y
+
yy[k] < Height
and
Pixels[x
+
xx[k], y
+
yy[k]]
=
=
TEXTCOLOR:
MaxX
=
max
(MaxX, x
+
xx[k])
MinX
=
min
(MinX, x
+
xx[k])
MaxY
=
max
(MaxY, y
+
yy[k])
MinY
=
min
(MinY, y
+
yy[k])
Pixels[x
+
xx[k], y
+
yy[k]]
=
TEMPCOLOR
Points.append((x
+
xx[k], y
+
yy[k]))
TempBlock
=
Picture.crop((MinX, MinY, MaxX
+
1
, MaxY
+
1
))
TempPixels
=
TempBlock.load()
BlockWidth
=
MaxX
-
MinX
+
1
BlockHeight
=
MaxY
-
MinY
+
1
for
y
in
xrange
(BlockHeight):
for
x
in
xrange
(BlockWidth):
if
TempPixels[x, y] !
=
TEMPCOLOR:
TempPixels[x, y]
=
BACKCOLOR
else
:
TempPixels[x, y]
=
TEXTCOLOR
Pixels[MinX
+
x, MinY
+
y]
=
BACKCOLOR
TempBlocks
=
SplitCharacter(TempBlock)
for
TempBlock
in
TempBlocks:
Blocks.append(TempBlock)
return
Blocks
|
字符識別
這里我將分隔出來的字符塊與模板庫中的字符信息進行比對,距離越小相似度越大。關於距離這里推薦使用編輯距離(Levenshtein Distance),他與漢明距離相比可以更好的抵抗字符因輕微的扭曲、旋轉等變換而帶來的誤差。
為提高識別的精確度,我取了距離最小的前TopConut個字符信息來計算其中出現的每個字符與待識別字符的加權距離。我們令第i個字符的權重為TopConut - i,那么字符x與待識別字符的加權距離為:
其中Disi是第i個字符信息與待識別字符的距離,i取前TopCount個字符信息中所有字符為x的下標。
0x03 最后
至此,一個驗證碼的識別已經全部完成了。
在整個驗證碼識別過程中有兩個關鍵之處:一是有效信息的提取,只要提取出來較好質量的有效信息才能在識別時取得較高的識別率;二是字符的分割,現有的很多算法對單個字符的識別已經有較高的的識別率了,因此,如何較好的分隔字符也成為了驗證碼識別的關鍵。
知道了攻擊的關鍵我們就可以有針對性的來改進我們的驗證碼了。對於設計驗證碼的一個基本原則就是利用人類識別與機器自動識別的差異來設計。這里我再給出幾個我個人認為值得考慮的地方:
好的粘連可以有效的避免常見的字符分割算法;
讓前景與背景具有相近的像素可以避免直接利用閾值法除去干擾信息;
在一定程度上要減少冗余,冗余越大,提供的信息越多,越容易被識別