初探驗證碼識別

wyzsk發表於2020-08-19
作者: sdc1992 · 2015/01/09 15:18

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色灰度圖)的灰度直方圖後可以看到該圖片在各灰度級上的畫素分佈數量。以下圖的驗證碼為例,我們可以看到最左邊(即純黑色)與右側其他灰度級畫素的分佈有明顯一段隔開的區域,而圖中純黑色區域正好是有效資訊(即驗證碼)。因此我們可以在該段隔開的區域裡設一個閾值,畫素值大於閾值的置為白色,小於畫素值的置為黑色。

enter image description here

enter image description here

enter image description here

下圖為透過上述辦法二值化後的結果,背景已完全被去除,而有效資訊被完整的保留了下來。

enter image description here

但是有時當前景與背景畫素的灰度值交織在一起時,我們則很難透過閾值法提取出有效資訊。以下面這張驗證碼為例,我們可以從其灰度直方圖中看到所有畫素點幾乎都聚集在了一起。

enter image description here

enter image description here

enter image description here

enter image description here

enter image description here

我們將閾值設在峰值左側嘗試二值化,可以從結果看出,這時有效資訊非但沒有被提取出來,反而帶入了更強的干擾。對於此類驗證碼我們則需要在二值化之前先去除干擾。

程式碼如下:

#!python
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畫素。針對此特性我設計了一種分離有效資訊和干擾資訊的演算法。

enter image description here

enter image description here

具體演算法過程如下:

將驗證碼轉成256色灰度影像後,用一個33的視窗以此覆蓋影像中的每一個畫素點,然後將視窗中9個點的畫素值進行排序後取中值Vmid,比較Vmid與33視窗中中心畫素的值。如果二者差值大於預設的閾值,則判斷該點顏色接近於白色還是黑色,若接近白色則將該點置為白色(255),若接近於黑色則置為黑色(0)。重複三次左右即可得到一個基本穩定的結果。

enter image description here

enter image description here

透過對比可以看出處理後的驗證碼區域灰度已被加深成黑色,與干擾線和背景的顏色已經明顯區分開。從處理後的灰度直方圖可以看出,畫素點已主要集中在黑色(0)和白色(255)兩個灰度級,這時在用閾值法二值化即可得到一個比較令人滿意的結果了。

enter image description here

enter image description here

程式碼如下:

#!python
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

降噪


雖然上面結果的質量已經足以用於識別了,但我們仍然可以看到圖中存在明顯的噪聲,我們還可以透過降噪將其質量進一步提高。

降噪的主要目的是去除影像中的噪聲,降噪方法有方法有很多如:平滑、低通濾波等……這裡介紹一種相對簡單的方法——平滑降噪。具體方法是透過統計每個畫素點周圍畫素值的個數來判斷將改點置為和值。如果一個點周圍白色點的個數大於某一閾值則將改點置為白色,反之亦然。透過平滑降噪已經可以將剩下的噪聲點全部去除了。

enter image description here

這裡需要注意的是對二值影像進行降噪時應注意強度,當驗證碼筆畫較細時,降噪強度過大可能會破壞驗證碼本身的資訊,這可能會影響到後面的識別效果。

程式碼如下:

#!python
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

字元分割


得到經過預處理的圖片後需要將每個字元單獨分隔出來,這裡簡單介紹幾種字元分隔的方法。

投影法


投影法是根據圖片在投影方向上的畫素個數進行分割的。

enter image description here

enter image description here

統計之前經過預處理影像在豎直方向上的畫素個數可以看到每兩個字元之間的畫素個數有明顯斷開的情況。因此,我們在這些斷開處進行分隔即可。

投影法對於處理字元在投影方向上分佈比較開的情況有比較好的效果,但是如果遇到當兩個字元在有影方向上有交集的情況則可能將兩個字元誤判成一個字元。

連通區域法


如果兩個點相鄰切顏色相同,則稱這兩個點是連通的。從一個點開始,所有與它直接或簡介連通的點集即為一個連通區域。 連通區域法是從一個點開始找其連通區域,然後將每一個連通區域分割成一個塊。

enter image description here

這樣每個字元都將作為一個連通區域沒分割出來。下圖中每一種顏色是一個連通區域。

連通區域法可以很好解決兩個字元雖然在有影方向上有交集可是沒有粘連的情況,但是如果兩個字元粘連在一起的話連通區域法也會將兩個字元誤判成一個。

對粘連字元的處理


如果對於上述情況我們可以透過最大字元寬度來判斷連個字元是否發生粘連。我們可以先統計一些字元,記下最大字元寬度,當用連通區域法分隔出的字元寬度大於最大字元寬度時,我們則認為這是粘連字元。

這裡介紹兩種處理粘連字元的方法:

1. 根據平均字元寬度找極小值點分割字元

我們同樣先統計一些字元,記下平均字元寬度,當遇到兩個字元粘連時,從平均字元寬度處向兩側找豎直方向上有效畫素個數的極小值點,然後從極小值點進行分割。

enter image description here

這種方法雖然在一定程度上可以解決粘連字元的問題,但是可能會破壞部分字元,這樣可能對之後的識別造成干擾。

2. 滴水演算法

未解決上述問題提出了滴水演算法。滴水演算法的過程是從圖片頂部開始向下走,向水滴滴落一樣沿著字元輪廓下滑,當滴到輪廓凹處滲入筆畫,穿過筆畫後繼續滴落,最終水滴所經過的軌跡就構成了字元的分割路徑。[ 2 ]

enter image description here

從上圖可以看出粘連字元較好的被分割開並且在最大程度上保護了每一個字元的原貌。

程式碼如下:

#!python
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與待識別字元的加權距離為:

enter image description here

其中Disi是第i個字元資訊與待識別字元的距離,i取前TopCount個字元資訊中所有字元為x的下標。

0x03 最後


至此,一個驗證碼的識別已經全部完成了。

在整個驗證碼識別過程中有兩個關鍵之處:一是有效資訊的提取,只要提取出來較好質量的有效資訊才能在識別時取得較高的識別率;二是字元的分割,現有的很多演算法對單個字元的識別已經有較高的的識別率了,因此,如何較好的分隔字元也成為了驗證碼識別的關鍵。

知道了攻擊的關鍵我們就可以有針對性的來改進我們的驗證碼了。對於設計驗證碼的一個基本原則就是利用人類識別與機器自動識別的差異來設計。這裡我再給出幾個我個人認為值得考慮的地方:

好的粘連可以有效的避免常見的字元分割演算法;

讓前景與背景具有相近的畫素可以避免直接利用閾值法除去干擾資訊;

在一定程度上要減少冗餘,冗餘越大,提供的資訊越多,越容易被識別

本文章來源於烏雲知識庫,此映象為了方便大家學習研究,文章版權歸烏雲知識庫!

相關文章