1 摘要
驗證碼是目前互聯網上很是常見也是很是重要的一個事物,充當着不少系統的 防火牆 功能,可是隨時OCR技術的發展,驗證碼暴露出來的安全問題也愈來愈嚴峻。本文介紹了一套字符驗證碼識別的完整流程,對於驗證碼安全和OCR識別技術都有必定的借鑑意義。html
2 關鍵詞
關鍵詞:安全,字符圖片,驗證碼識別,OCR,Python,SVM,PILpython
3 免責聲明
本文研究所用素材來自於某舊Web框架的網站 徹底對外公開 的公共圖片資源。git
本文只作了該網站對外公開的公共圖片資源進行了爬取, 並未越權 作任何多餘操做。程序員
本文在書寫相關報告的時候已經 隱去 漏洞網站的身份信息。github
本文做者 已經通知 網站相關人員此係統漏洞,並積極向新系統轉移。web
本報告的主要目的也僅是用於 OCR交流學習 和引發你們對 驗證安全的警覺 。算法
4 引言
關於驗證碼的非技術部分的介紹,能夠參考之前寫的一篇科普類的文章:編程
http://www.cnblogs.com/beer/p/4996833.html
裏面對驗證碼的種類,使用場景,做用,主要的識別技術等等進行了講解,然而並無涉及到任何技術內容。本章內容則做爲它的 技術補充 來給出相應的識別的解決方案,讓讀者對驗證碼的功能及安全性問題有更深入的認識。安全
5 基本工具
要達到本文的目的,只須要簡單的編程知識便可,由於如今的機器學習領域的蓬勃發展,已經有不少封裝好的開源解決方案來進行機器學習。普通程序員已經不須要了解複雜的數學原理,便可以實現對這些工具的應用了。
主要開發環境:
-
- python3.5
-
python SDK版本
-
- PIL
-
圖片處理庫
-
- libsvm
-
開源的svm機器學習庫
關於環境的安裝,不是本文的重點,故略去。
6 基本流程
通常狀況下,對於字符型驗證碼的識別流程以下:
- 準備原始圖片素材
- 圖片預處理
- 圖片字符切割
- 圖片尺寸歸一化
- 圖片字符標記
- 字符圖片特徵提取
- 生成特徵和標記對應的訓練數據集
- 訓練特徵標記數據生成識別模型
- 使用識別模型預測新的未知圖片集
- 達到根據「圖片」就能返回識別正確的字符集的目標
7 素材準備
7.1 素材選擇
因爲本文是以初級的學習研究目的爲主,要求 「有表明性,但又不會太難」 ,因此就直接在網上找個比較有表明性的簡單的字符型驗證碼(感受像在找漏洞同樣)。
最後在一個比較舊的網站(估計是幾十年前的網站框架)找到了這個驗證碼圖片。
原始圖:
放大清晰圖:
此圖片能知足要求,仔細觀察其具備以下特色。
有利識別的特色 :
- 由純阿拉伯數字組成
- 字數爲4位
- 字符排列有規律
- 字體是用的統一字體
以上就是本文所說的此驗證碼簡單的重要緣由,後續代碼實現中會用到
不利識別的特色 :
- 圖片背景有干擾噪點
這雖然是不利特色,可是這個干擾門檻過低,只須要簡單的方法就能夠除去
7.2 素材獲取
因爲在作訓練的時候,須要大量的素材,因此不可能用手工的方式一張張在瀏覽器中保存,故建議寫個自動化下載的程序。
主要步驟以下:
- 經過瀏覽器的抓包功能獲取隨機圖片驗證碼生成接口
- 批量請求接口以獲取圖片
- 將圖片保存到本地磁盤目錄中
這些都是一些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張驗證素材了。
下面是收集的幾十張素材庫保存到本地文件的效果圖:
8 圖片預處理
雖然目前的機器學習算法已經至關先進了,可是爲了減小後面訓練時的複雜度,同時增長識別率,頗有必要對圖片進行預處理,使其對機器識別更友好。
針對以上原始素材的處理步驟以下:
- 讀取原始圖片素材
- 將彩色圖片二值化爲黑白圖片
- 去除背景噪點
8.1 二值化圖片
主要步驟以下:
- 將RGB彩圖轉爲灰度圖
- 將灰度圖按照設定閾值轉化爲二值圖
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 的骨架了。
8.2 去除噪點
在轉化爲二值圖片後,就須要清除噪點。本文選擇的素材比較簡單,大部分噪點也是最簡單的那種 孤立點,因此能夠經過檢測這些孤立點就能移除大量的噪點。
關於如何去除更復雜的噪點甚至干擾線和色塊,有比較成熟的算法: 洪水填充法 Flood Fill ,後面有興趣的時間能夠繼續研究一下。
本文爲了問題簡單化,乾脆就用一種簡單的本身想的 簡單辦法 來解決掉這個問題:
- 對某個 黑點 周邊的九宮格里面的黑色點計數
- 若是黑色點少於2個則證實此點爲孤立點,而後獲得全部的孤立點
- 對全部孤立點一次批量移除。
下面將詳細介紹關於具體的算法原理。
將全部的像素點以下圖分紅三大類
- 頂點A
- 非頂點的邊界點B
- 內部點C
種類點示意圖以下:
- A類點計算周邊相鄰的3個點(如上圖紅框所示)
- B類點計算周邊相鄰的5個點(如上圖紅框所示)
- C類點計算周邊相鄰的8個點(如上圖紅框所示)
固然,因爲基準點在計算區域的方向不一樣,A類點和B類點還會有細分:
- A類點繼續細分爲:左上,左下,右上,右下
- B類點繼續細分爲:上,下,左,右
- C類點不用細分
而後這些細分點將成爲後續座標獲取的準則。
主要算法的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 的點的座標即爲 孤立點 。這個判斷方法可能不太準確,可是基本上可以知足本文的需求了。
通過預處理後的圖片以下所示:
對比文章開頭的原始圖片,那些 孤立點 都被移除掉,相對比較 乾淨 的驗證碼圖片已經生成。
9 圖片字符切割
因爲字符型 驗證碼圖片 本質就能夠看着是由一系列的 單個字符圖片 拼接而成,爲了簡化研究對象,咱們也能夠將這些圖片分解到 原子級 ,即: 只包含單個字符的圖片。
因而,咱們的研究對象由 「N種字串的組合對象」 變成 「10種阿拉伯數字」 的處理,極大的簡化和減小了處理對象。
9.1 分割算法
現實生活中的字符驗證碼的產生千奇百怪,有各類扭曲和變形。關於字符分割的算法,也沒有很通用的方式。這個算法也是須要開發人員仔細研究所要識別的字符圖片的特色來制定的。
固然,本文所選的研究對象儘可能簡化了這個步驟的難度,下文將慢慢進行介紹。
使用圖像編輯軟件(PhoneShop或者其它)打開驗證碼圖片,放大到像素級別,觀察其它一些參數特色:
能夠獲得以下參數:
- 整個圖片尺寸是 40*10
- 單個字符尺寸是 6*10
- 左右字符和左右邊緣相距2個像素
- 字符上下緊挨邊緣(即相距0個像素)
這樣就能夠很容易就定位到每一個字符在整個圖片中佔據的像素區域,而後就能夠進行分割了,具體代碼以下:
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
而後就能獲得被切割的 原子級 的圖片元素了:
9.2 內容小結
基於本部分的內容的討論,相信你們已經瞭解到了,若是驗證碼的干擾(扭曲,噪點,干擾色塊,干擾線……)作得不夠強的話,能夠獲得以下兩個結論:
-
4位字符和40000位字符的驗證碼區別不大
-
- 純數字 和 數字及字母組合 的驗證碼區別不大
-
-
純數字。分類數爲10
-
- 純字母
-
- 不區分大小寫。分類數爲26
- 區分大小寫。分類數爲52
-
數字和區分大小寫的字母組合。分類數爲62
-
在沒有造成 指數級或者幾何級 的難度增長,而只是 線性有限級 增長計算量時,意義不太大。
10 尺寸歸一
本文所選擇的研究對象自己尺寸就是統一狀態:6*10的規格,因此此部分不須要額外處理。可是一些進行了扭曲和縮放的驗證碼,則此部分也會是一個圖像處理的難點。
11 模型訓練步驟
在前面的環節,已經完成了對單個圖片的處理和分割了。後面就開始進行 識別模型 的訓練了。
整個訓練過程以下:
- 大量完成預處理並切割到原子級的圖片素材準備
- 對素材圖片進行人爲分類,即:打標籤
- 定義單張圖片的識別特徵
- 使用SVM訓練模型對打了標籤的特徵文件進行訓練,獲得模型文件
12 素材準備
本文在訓練階段從新下載了同一模式的4數字的驗證圖片總計:3000張。而後對這3000張圖片進行處理和切割,獲得12000張原子級圖片。
在這12000張圖片中刪除一些會影響訓練和識別的強幹擾的干擾素材,切割後的效果圖以下:
13 素材標記
因爲本文使用的這種識別方法中,機器在最開始是不具有任何 數字的觀念的。因此須要人爲的對素材進行標識,告訴 機器什麼樣的圖片的內容是 1……。
這個過程叫作 「標記」。
具體打標籤的方法是:
-
爲0~9每一個數字創建一個目錄,目錄名稱爲相應數字(至關於標籤)
-
人爲斷定 圖片內容,並將圖片拖到指定數字目錄中
-
- 每一個目錄中存放100張左右的素材
-
通常狀況下,標記的素材越多,那麼訓練出的模型的分辨能力和預測能力越強。例如本文中,標記素材爲十多張的時候,對新的測試圖片識別率基本爲零,可是到達100張時,則能夠達到近乎100%的識別率
14 特徵選擇
對於切割後的單個字符圖片,像素級放大圖以下:
從宏觀上看,不一樣的數字圖片的本質就是將黑色按照必定規則填充在相應的像素點上,因此這些特徵都是最後圍繞像素點進行。
字符圖片 寬6個像素,高10個像素 ,理論上能夠最簡單粗暴地能夠定義出60個特徵:60個像素點上面的像素值。可是顯然這樣高維度必然會形成過大的計算量,能夠適當的降維。
經過查閱相應的文獻 [2],給出另一種簡單粗暴的特徵定義:
- 每行上黑色像素的個數,能夠獲得10個特徵
- 每列上黑色像素的個數,能夠獲得6個特徵
最後獲得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 指定的格式生成一組帶特徵值和標記值的向量文件。內容示例以下:
說明以下:
- 第一列是標籤列,即此圖片人爲標記值,後續還有其它數值1~9的標記
- 後面是16組特徵值,冒號前面是索引號,後面是值
- 若是有1000張訓練圖片,那麼會產生1000行的記錄
對此文件格式有興趣的同窗,能夠到 libSVM 官網搜索更多的資料。
15 模型訓練
到這個階段後,因爲本文直接使用的是開源的 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
16 模型測試
訓練生成模型後,須要使用 訓練集 以外的全新的標記後的圖片做爲 測試集 來對模型進行測試。
本文中的測試實驗以下:
- 使用一組所有標記爲8的21張圖片來進行模型測試
- 測試圖片生成帶標記的特徵文件名稱爲 last_test_pix_xy_new.txt
在早期訓練集樣本只有每字符十幾張圖的時候,雖然對訓練集樣本有很好的區分度,可是對於新樣本測試集基本沒區分能力,識別基本是錯誤的。逐漸增長標記爲8的訓練集的樣本後狀況有了比較好的改觀:
- 到60張左右的時候,正確率大概80%
- 到185張的時候,正確率基本上達到100%
以數字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('')
至此,驗證的識別工做算是完滿結束。
17 完整識別流程
在前面的環節,驗證碼識別 的相關工具集都準備好了。而後對指定的網絡上的動態驗證碼造成持續不斷地識別,還須要另外寫一點代碼來組織這個流程,以造成穩定的黑盒的驗證碼識別接口。
主要步驟以下:
- 傳入一組驗證碼圖片
- 對圖片進行預處理:去噪,二值等等
- 切割成4張有序的單字符圖片
- 使用模型文件分別對4張圖片進行識別
- 將識別結果拼接
- 返回識別結果
而後本文中,請求某網絡驗證碼的http接口,得到驗證碼圖片,識別出結果,以此結果做爲名稱保存此驗證圖片。效果以下:
顯然,已經達到幾乎 100% 的識別率了。
在本算法沒有作任何優化的狀況下,在目前主流配置的PC機上運行此程序,能夠實現200ms識別一個(很大的耗時來自網絡請求的阻塞)。
18 效率優化
後期經過優化的方式能夠達到更好的效率。
軟件層次優化
- 將圖片資源的網絡請求部分作成異步非阻塞模式
- 利用好多核CPU,多進程並行運行
- 在圖片特徵上認真挑選和實驗,下降維度
預計能夠達到1s識別10到100個驗證碼的樣子。
硬件層次優化
- 粗暴地增長CPU性能
- 粗暴地增長運行機器
基本上,10臺4核心機器同時請求,保守估計效率能夠提高到1s識別1萬個驗證碼。
19 互聯網安全警示
若是驗證碼被識別出來後,會有什麼安全隱患呢?
在你們經過上一小節對識別效率有了認識以後,再提到這樣的場景,你們會有新的見解了吧:
- 12306火車售票網,春節期間早上8:00某車次放出的500張票,1s內所有被搶光,最後發現正常需求的人搶不到票,可是黃牛卻大大的有票
- 某某手機網站,早上10:00開啓搶購活動,守候了許久的無數的你都鎩羽而歸,可是一樣黃牛卻大量有貨
暫先無論後面有沒有手續上的黑幕,在一切手續合法的狀況下,只要經過技術手段識別掉了驗證碼,再經過計算機強大的計算力和自動化能力,將大量資源搶到少數黃牛手中在技術是徹底可行的。
因此從此你們搶不到票不爽的時候,能夠繼續罵12306,可是不要罵它有黑幕了,而是罵他們IT技術不精吧。
關於一個驗證碼失效,即至關於沒有驗證碼的系統,再沒有其它風控策略的狀況下,那麼這個系統對於代碼程序來就就徹底如入無人之境。
具體請參考:
http://www.cnblogs.com/beer/p/4814587.html
經過上面的例子,你們能夠看到:
- 目前確實有一些web應用系統連驗證碼都沒有,只能任人宰割
- 即便web應用系統有驗證碼可是難度不夠,也只能任人宰割
因此,這一塊雖然小,可是安全問題不能忽視。
20 積極應用場景
本文介紹的實際上是一項簡單的OCR技術實現。有一些很好同時也頗有積極進步意義的應用場景:
- 銀行卡號識別
- 身份證號識別
- 車牌號碼識別
這些場景有具備和本文所研究素材很類似的特色:
- 字體單一
- 字符爲簡單的數字或字母組合
- 文字的排列是標準化統一化的
因此若是拍照時原始數據採集比較規範的狀況下,識別起來應該難度也不大。
21 小結
本文只是選取了一個比較典型的並且比較簡單的驗證碼的識別做爲示例,可是基本上能表述出一個識別此類驗證碼的完整流程,能夠供你們交流學習。
因爲目前全球的IT技術實力良莠不齊,如今不少舊的IT系統裏面都存在一些舊的頁面框架,裏面使用的驗證碼也是至關古老,對於當下的一些識別技術來講,徹底不堪一擊。好比,我看到一些在校大學生就直接拿本身學校的 教務系統 的驗證碼來 開刀練習 的。
最後,本文特地提出以下倡議:
-
- 對於掌握OCR技術的人
-
- 不要作違法的事,由於目前被抓的「白帽子」的新聞也蠻多的
- 在不違法的狀況下,仍是能夠向存在漏洞的系統管理員提出善意提醒
- 以本身的專業知識,多作一些促進社會進步,提高社會生產力的事情,如紙書電子化等等
-
- 對於仍然沿用舊的落後的IT系統的公司或者機構相關人員
-
應該儘快認識到事情的嚴重性,趕忙升級本身的系統,或者將這一塊業務交付給專門的安全公司
22 參考資料
[1] | LibSVM for Python 使用 http://www.cnblogs.com/Finley/p/5329417.html |
[2] | 基於SVM的手寫體阿拉伯數字識別.張鴿,陳書開.長沙理工大學計算機通信工程學院.2005 |
23 最後題外話
我估計這樣 長文 絕大部分人是不會有興趣所有看完的。但爲了它的內容完整性,仍是決定先以整篇的方式發表出來吧。
後面有空再拆分連載吧。
源碼開源共享:
https://github.com/zhengwh/captcha-svm