理解 MD5 消息摘要算法

MD5 算法相信很多開發人員都聽說過, 一個最常見的使用到它的地方就是密碼的存儲.

當然, 很多人會說, 這個算法已經不太安全了, 確實如果你想更安全的保存密碼, 則應該考慮其它更安全的算法, 不過這不屬於此次討論的主題.

什麼是 MD5

MD5 是一種算法, MD5 中的 MD 代表 Message Digest, 也即信息摘要.

至於數字 5, 則因它是從更早的 MD4 算法改進而來, 因此得名 MD5.

所以 MD5 即是信息摘要算法第五版.

什麼是信息摘要算法

那什麼又是信息摘要算法呢? 它本質上就是一個哈希函數(hash function).

又叫散列函數.

那什麼又是哈希函數呢? 它是指這樣一種函數: 它能把任意大小的數據映射(map)爲一個固定大小的值.

A hash function is any function that can be used to map data of arbitrary size to fixed-size values.

哈希函數所返回的這個值稱爲哈希值(hash value), 又稱爲哈希碼(hash codes), 或直接簡稱爲哈希(hash).

具體例子

單純地這樣去講會比較抽象, 因此這裏引入具體例子來說明, 以 Java 爲例, 可以這樣去計算 MD5:

public void rawMd5() throws NoSuchAlgorithmException {
    byte[] bytes = "hello".getBytes(StandardCharsets.UTF_8);
    MessageDigest md = MessageDigest.getInstance("MD5");
    byte[] md5 = md.digest(bytes);
}

注: 以上代碼計算了"hello"這個字符串對應的 utf-8 字節數組的 md5 的值.

另注: 因爲摘要計算的輸入是一個字節數組, 如果要計算字符串的摘要值, 則要轉成某種編碼的字節數組, 爲保持一致, 應始終顯式使用同一種編碼, 比如 utf-8.

從以上代碼中也不難看出, 一個 MD5 函數的輸入和輸出都是字節數組 byte[].

而代碼中不能直接體現的一點則是: 輸入可以是任意大小的字節數組, 輸出則是固定大小的字節數組.

對於 MD5 算法而言, 這個輸出值是一個固定大小爲 16 字節的數組, 然後因爲每個字節(byte)有 8 個位(bit), 所以最終的輸出值是一個 16 × 8 = 128 位的二進制數. MD5 的值就是一個 128 位的二進制大整數.

比如下面就是一個具體的 MD5 的值, 以原始的 128 位二進制形式表示: 10001000100100011001000111110000100011111000000111010010110010101100010111101010000110011011110000111011111101111101100110111110

這個 MD5 值實際是對我的網站域名 xiaogd.net 作摘要的結果.

這個值的二進制形式實在是長得不要不要的, 所以一般會轉換爲十六進制形式, 共 16 組具體爲: 88 91 91 f0 8f 81 d2 ca c5 ea 19 bc 3b f7 d9 be. 依然還是很長, 但比二進制好多了.

隨便說一句, IPv6 的地址也是 128 bit 的, 所以也是像這般變態的長, 寫成 16 進制也還是很長, 壓根記不住...

最後通常還會去掉空格寫成一個緊湊的 32 個字符的字符串的形式: 889191f08f81d2cac5ea19bc3bf7d9be, 也即是我們最常見到的 MD5 值的形式.

但請不要誤解, MD5 的值並不是一個字符串, 更不是什麼字母都能出現在裏邊的.

一個完整的代碼示例如下, 使用 apache commons-codec 工具包的 Hex 工具類將字節數組轉爲 16 進制字符串形式:

package net.xiaogd.demo.basic;

import org.apache.commons.codec.binary.Hex;
import org.junit.Assert;
import org.junit.Test;

import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

public class Md5Test {

    @Test
    public void calcMd5() {
        Assert.assertEquals("889191f08f81d2cac5ea19bc3bf7d9be", md5("xiaogd.net"));
    }

    public static String md5(String input) {
        try {
            byte[] bytes = input.getBytes(StandardCharsets.UTF_8);
            MessageDigest md = MessageDigest.getInstance("MD5");
            byte[] hash = md.digest(bytes);

            // use apache commons-codec util jar
            return Hex.encodeHexString(hash);
        } catch (NoSuchAlgorithmException e) {
            throw new RuntimeException("md5 not support!", e);
        }
    }
}

所以如果你直接存儲這個整數值的字節數組, 你只需要 16 字節的空間, 但如果你存儲轉爲 16 進製表示的字符串, 因爲最終結果是 32 個字符, 那麼就至少需要 32 字節(ASCII) 空間了.

在數據庫存儲時, 通常會存爲固定長度的 char(32), 編碼選 ASCII, 因爲所有字符都是 ASCII 字符, 而 ASCII 字符每個只需一字節空間去保存, 如果用其它字符集, 需要的空間可能會翻倍, 甚至三倍, 四倍.

MD5 算法的特性

其實從上面的結果中, 我們已知道了 MD5 算法的一個特性, 那就是它的結果是一個固定 16 字節數組的值.

也就是說不管你的輸入是多長, 輸出的結果都是那麼長. 你輸入是一個字節, 輸出也是 16 字節, 你輸入是 100 個字節, 輸出也還是 16 字節.

因爲 MD5 算法經常用在密碼保存方面, 有些人就認爲它是一種加密算法, 那麼這種理解其實是有問題的. MD5 的所謂"加密"效果顯然與一般的加密-解密算法中的加密不是一回事.

MD5 是做摘要, 而不是加密, 如果它是加密, 之後還能解密的那種算法, 一個很自然的問題就是, 它輸出是固定的, 這就帶來一個問題, 就像前面說的, 輸入 100 個字節, 輸出還是 16 字節, 假如我的密碼就是一個很變態的 100 個字符長度的, 經過你的 MD5 所謂"加密"後, 成了一個 32 字符的值, 字符變少了, 信息肯定發生了丟失, 你怎麼可能從更少的 32 字符解密回去 100 個字符呢? 顯然是不可能辦到的.

另外一個顯然的問題則是, 加解密算法是要求密鑰的(或是一般意義上的密碼), 然後在加密以及解密的過程, 密鑰是要作爲參數參與方法調用的, 而我們看前述 MD5 值生成的代碼, 並沒有涉及密鑰.

而以上討論又自然引出了 MD5 的又一特性, MD5 的所謂"加密", 實際上是 "one-way hash", 也即是單向的不可逆的 hash 過程, 也即是不管你的輸入字節數是比輸出字節數大還是小, 你都不可能從輸出值去反推到輸入值.

這是 MD5 作爲一種 密碼學哈希函數(CHF: Cryptographic Hash Function) 所必須具備的一個特性.

不嚴謹的說法就是它是無法"解密"的, 但前面說了, 既然它都不是"加密", 也沒有密鑰, 也自然就無所謂解密了.

當然, 說到這裏, 有人可能又要爭辯說, MD5 是可以通過暴力方式解密的, 有所謂的 彩虹表(rainbow table) 可以倒推出原始的密碼.

嗯, 這麼說也確實是事實, 但具體解析這是怎麼回事, 包括使彩虹表成爲可能的, 則與 MD5 算法的另一個特性有很大關係: 那就是它是一種 確定性(deterministic) 的算法.

所謂確定性, 就是對於同樣的輸入, 總是產生同樣的輸出.

來看一個具體的代碼示例:

@Test
public void recalcMd5() {
   Assert.assertEquals("889191f08f81d2cac5ea19bc3bf7d9be", md5("xiaogd.net"));
   Assert.assertEquals("889191f08f81d2cac5ea19bc3bf7d9be", md5("xiaogd.net"));
}

注: md5 工具方法見前述代碼示例, 此處略

可以看到兩次對 xiaogd.net 這個字符串進行 MD5 的結果都是一樣的. 你不會碰到說, 同一個字符串第一次這個結果, 下一次又是另一個結果, 這樣就亂套了.

MD5 保證不會發生這樣的情況, 而這個確定性的特性也使得用 MD5 來存儲密碼成爲可能.

具體而言整個流程是這樣去實現的, 就比如說我準備註冊用戶 xgd, 然後用我的域名 xiaogd.net 作爲對應的密碼吧, 那麼在第一次用戶註冊的時候, 就把用戶名 xgd 和根據密碼 xiaogd.net 算出的 MD5 的值 889191f08f81d2cac5ea19bc3bf7d9be 存起來:

xgd --> 889191f08f81d2cac5ea19bc3bf7d9be

而密碼 xiaogd.net 本身則丟棄了, 不保存在數據庫中.

這樣的方案還是有問題的, 但這裏的目的不是要討論怎麼安全的存儲密碼, 而是探討 MD5 存儲密碼的原理, 因而採用了一個簡單的有缺陷的方案.

後續, 當用戶再次登錄時, 自然, 用戶還是傳過來 xgd 這個用戶名和 xiaogd.net 這個密碼, 而數據庫裏並沒有這個密碼, 怎麼去驗證呢?

答案也很簡單, 就是再次對用戶輸入的密碼進行 MD5 計算, 然後與數據庫保存的 MD5 值進行比較. 那麼根據算法的確定性的特徵, 如果用戶輸入的還是同樣的密碼, MD5 後的值自然也還是同樣的 889191f08f81d2cac5ea19bc3bf7d9be, 這樣就能與數據庫先前保存的值匹配上了, 也就可以判定允許用戶登錄了;

而用戶再次傳過來的密碼值在完成 MD5 運算後依然會被丟棄而不會存起來.

安全的做法必然是這樣去要求的, 那就是永遠都不要明碼保存用戶的密碼, 另一方面, 這也導致了"找回密碼"功能並不會真正讓你找回密碼, 只能讓你重新設置密碼, 然後覆蓋掉原有的哈希值.

反之, 假如用戶輸入了錯誤的密碼, 那麼經 MD5 計算就會產生不一樣的值:

事實上, 哪怕只有一點點的不同, 結果也會劇烈的變化, 有點像是 蝴蝶效應(Butterfly Effect) 或是 雪崩效應(Avalanche Effect) 那樣, 這也可以算是 MD5 算法的一個特性.

比如缺了點的 xiaogdnet, 可謂字面意義上的有一"點"不同, MD5 的結果就會變成這樣: dc0c594ffbc99b8a8d5a6fa022738ad6, 對比原來的 889191f08f81d2cac5ea19bc3bf7d9be, 結果可謂天差地別.

也就無法與數據庫保存的值匹配上, 這種情況下登錄就被拒絕了.

當然, 你可能好奇, 有沒有可能輸入了一個錯誤的密碼, 卻產生了相同的 MD5 值呢? 這實際就是所謂的 MD5 的 碰撞(Collision) 問題了, 我們留待後面再分析. 簡單講就是理論上是可能的, 這種可能性甚至有無數種, 但實際上, 想產生一個碰撞卻並不容易.

綜上, 此種密碼存儲及驗證方案, 在安全性上有兩個優勢:

  1. 沒有存儲用戶的原始的明文密碼, 所以即便是內鬼也不可能知道用戶的密碼;
  2. MD5 算法是不可逆的, 即便內鬼要搗亂或者數據庫泄露了, 也無法倒推出用戶的密碼.

既然如此, 所謂的暴力破解及彩虹表又是怎麼回事呢? 這就多少涉及到人性了.

安全界早就有這種共識: 那就是系統安全最薄弱的環節往往就是人本身.

在其它很多方面, 人的問題也常常出現, 見此處的一個調侃: PEBKAC

首先, 倒推確實是不可能, 但根據 MD5 算法的確定性, 卻可以進行正推.

所謂正推, 就好比你知道 xiaogd.net 經 MD5 會生成 889191f08f81d2cac5ea19bc3bf7d9be, 那麼當你拿到 889191f08f81d2cac5ea19bc3bf7d9be, 你反過來就知道了原始的密碼是 xiaogd.net.

咋一看時, 密碼的組合是無窮無盡的, 這使得正推存在很大的困難, 可問題也恰恰出在這裏, 很多用戶經常使用很簡單的弱密碼, 比如只使用 6 位的數字, 則所有的可能不過是 100 萬種而已.

那麼假如此時我想去破解, 我就先把 000000 ~ 999999 的一百萬個 MD5 值全部先算出來, 然後存到一個數據庫的表裏:

md5('000000') --> 670b14728ad9902aecba32e22fa4f6bd;

md5('000001') --> 04fc711301f3c784d66955d98d399afb;

...

...

md5('999998') --> 755af25720023b2f852105910b125ecc;

md5('999999') --> 52c69e3a57331081823331c4e69d3f2e;

此時, 假如某個泄露的用戶數據庫裏有條記錄的 md5 值爲 755af25720023b2f852105910b125ecc, 我不知道密碼, 但我反過來一查我的表, 發現這個 MD5 值正好與 md5('999998') 的值相同, 那麼顯然這個用戶的密碼就是 999998 了.

理論上講, 因爲存在碰撞的可能, 用戶的密碼也可能是另一個, 但從登錄的原理上看, 用 999998 也是可以登錄的, 產生碰撞的兩個或多個均能通過登錄校驗.

而這樣的一個表, 即是所謂的 彩虹表(rainbow table) 了, 如果你有足夠的資源, 對於位數有限的密碼組合, 是完全可能窮舉完的, 這種窮舉式的正向 暴力破解(brute-force attack) 是很難防止的.

當然, 從以上敘述中也不難明白, 所謂破解, 不是真的破解, 另外, 它只能破解常見的以及較短的弱密碼. 如果你使用一個位數很長, 又包含各種大寫小字母, 各種特殊符號的強密碼, 那麼一般來說, 即便有彩虹表也無法奈你何, 畢竟要窮舉如此之多的記錄是不可能的.

爲抵禦這種彩虹表攻擊, 可以在系統中透明地爲用戶的密碼額外拼接上一個隨機的字符串值, 即所謂的 鹽值(salt), 然後再計算密碼加鹽後的 md5 值並保存, 這個過程稱爲 加鹽.

驗證的過程也是類似的, 同樣是把再次傳過來的密碼加上鹽值去計算 md5, 並與數據庫保存的值去驗證.

舉個具體例子, 先生成一個隨機的字符串, 也即鹽值, 比如"e3f$8%0"

爲敘述方便, 這裏用了一個較短的隨機值, 實際中可以考慮更長的隨機值.

如果張三此時用了一個簡單的密碼 666666, 拼接後計算的是 666666e3f$8%0 的 md5 值, 那麼這個串在那些暴力破解的彩虹表裏就不一定有了.

李四用了一個簡單的密碼 888888, 拼接後計算的是 888888e3f$8%0 的 md5 值, 同樣的這個串在那些暴力破解的彩虹表裏也不一定有.

更加安全的做法是爲每個用戶都生成專屬的隨機串, 然後把這個鹽值存在對應用戶記錄中, 也即存儲"用戶名 | 隨機鹽值 | 密碼+隨機鹽值的 md5 值".

舉個例子, 王五用了 123456 作爲密碼, 趙六也用了 123456 作爲密碼, 爲王五生成他專門的隨機鹽值 a&58#93, 爲趙六也生成他專屬的隨機鹽值 b78!967, 雖然他們都用了相同的密碼, 但拼上不同的鹽值後, 計算到的 md5 自然也不同了.

對於王五, 算的是 123456a&58#93 的 md5 值, 對於趙六, 算的是 123456b78!967 的 md5 值, 數據庫裏存儲的記錄是這樣的:

用戶名 | 隨機鹽值 | 密碼+隨機鹽值的 md5 值

王五 | a&58#93 | eed2402dbd1605cb1fa8fe5a270a1849

趙六 | b78!967 | fcbb4aa14c17bd8970e664e23c0fd87c

這樣一個數據庫即便被泄露了, 攻擊者也很難倒推出密碼, 他也無從知道誰誰誰是否使用了相同的密碼.

注意, 實踐中也有人採取二次 md5 的做法, 比如對於 123456 這個密碼, 存儲的是 md5(md5('123456')), 這種方式其實是不安全的!!

攻擊者也完全可以對那些簡單的密碼進行二次 md5 計算, 這個計算量不過翻了一倍而已, 完全可以再構建一個這樣的表.

md5('123456') = e10adc3949ba59abbe56e057f20f883e;

md5('e10adc3949ba59abbe56e057f20f883e') = 14e1b600b1fd579f47433b88e8d85291;

如果攻擊者發現一條記錄值是 14e1b600b1fd579f47433b88e8d85291, 則第一次查表他得到了 'e10adc3949ba59abbe56e057f20f883e', 問題是誰會去用如此複雜的密碼呢? 於是他猜測也許用了多次的 md5, 於是他用 e10adc3949ba59abbe56e057f20f883e 再次去反查, 就得到了最終的密碼 123456.

最後說說所謂的碰撞問題. 事實上, 從固定 16 字節的輸出結果來看, 而另一方面輸入的可能性則是無限的, 那麼這就從原理上決定了碰撞是無可避免的.

吾生也有涯,而知也無涯。以有涯隨無涯,殆已! --<<莊子·養生主>>

有一個所謂的抽屜原理, 舉個簡單的例子來說, 你有 10 個蘋果, 要放到 9 個抽屜裏, 然後我不需要知道你到底是怎麼放的, 我都可以斷言, 至少有一個抽屜裏面至少有兩個蘋果.

爲啥我可以這麼斷言呢? 很簡單, 即便前 9 個蘋果你放得非常均勻, 9 個抽屜每個都恰好放一個, 你手裏還是會剩下一個無處可去, 而每個抽屜此時都放了有蘋果, 因此最後無論你把這僅剩的蘋果放在哪個抽屜裏, 那那個抽屜裏就有兩個蘋果, 因此就得出了前述的結論: 至少有一個抽屜裏面至少有兩個蘋果. 蘿蔔太多, 坑位不夠, 還能咋地?

如果放得不均勻, 有些抽屜裏最後可能還不止兩個蘋果, 三個, 四個乃至更多都有可能.

那麼對於 MD5 來說, 原理是類似的, 因爲輸出是固定的 128 比特, 也就限制了所有可能的值是 2^128(2 的 128 次方) 種, 也就是值的範圍是從 0 ~ 2^128-1, 撐死了就這麼多不同種輸出.

注: 其實這個值相當大了, 它約等於 3.4×10^38, 340萬億億億億.

儘管這個空間是非常非常的大, 但輸入的可能性更大, 是無窮的, 就好比你有一個相當大數目的抽屜, 可我卻有無窮多個蘋果, 我怎麼都能把你的抽屜給塞滿了.

一個很簡單的方式就是, 我就從 md5('0'), md5('1'), md5('2')... 一直算到 md5('2^128')(注: 展開後的值, 這裏爲簡化才寫成指數形式), 那麼我就有了 2^128+1 種不同輸入, 而輸出受固定位的影響最多也只有 2^128 種不同結果, 那麼根據抽屜原理, 裏面就至少發生了一次碰撞, 也即必然有兩個不同的輸入值, 最後產生了相同的輸出值.

當然了, 實際上要產生一個碰撞則是很困難的, 一方面是這個空間非常大, 另一方面則是哈希函數的特性了, 還記得它也叫 散列函數 嗎? 可以理解爲它就是儘量把值分得散散的.

可以這麼去認爲, 對於兩個不同的字符串進行各自進行 md5 的值, 就相當於在這個空間隨機返回兩個數. 哪怕就是在 0~100 間隨機返回兩個數, 相同的概率也不高, 何況如此之大的一個範圍呢.

儘管碰撞是如此之難, 但算法畢竟不是完美的, 還是有人根據 md5 算法的一些缺陷構建出了一些碰撞的例子.

下面是一個具體碰撞的例子(注意有兩處加粗的部分是不同的, 另注: 是它們所代表的字節數組而不是字符串, 這是兩個十六進制的大數):

4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2

4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2

但它們都會生成這個相同的 md5 值: 008ee33a9d58b51cfeb425b0959121c9.

以下爲測試的相關代碼:

@Test
public void md5Collision() throws Exception {
    byte[] b1 = Hex.decodeHex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2");
    byte[] b2 = Hex.decodeHex("4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2");

    MessageDigest md = MessageDigest.getInstance("MD5");

    byte[] h1 = md.digest(b1);
    byte[] h2 = md.digest(b2);

    String h1Str = Hex.encodeHexString(h1);
    String h2Str = Hex.encodeHexString(h2);

    //System.out.println(h1Str);
    //System.out.println(h2Str);

    Assert.assertEquals(h1Str, h2Str);
}

注意: 是直接取 16 進制字符串代表的字節數組, 即 Hex.decodeHex 中的做法, 而不是取字符串的 string.getBytes, 直接轉換與 string.getBytes 得到的字節數組是不一樣的.

關於 md5 的介紹就到這裏.

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章