一文讀懂 MD5 算法

消息摘要算法是密碼學算法中非常重要的一個分支,它通過對所有數據提取指紋信息以實現數據簽名、數據完整性校驗等功能,由於其不可逆性,有時候會被用做敏感信息的加密。消息摘要算法也被稱為哈希(Hash)算法或散列算法。

任何消息經過散列函數處理後,都會獲得唯一的散列值,這一過程稱為 “消息摘要”,其散列值稱為 “數字指紋”,其算法自然就是 “消息摘要算法”了。換句話說,如果其數字指紋一致,就說明其消息是一致的。

一文讀懂 MD5 算法

(圖片來源 —— https://zh.wikipedia.org/wiki/散列函數)

消息摘要算法的主要特徵是加密過程不需要密鑰,並且經過加密的數據無法被解密,目前可以解密逆向的只有 CRC32 算法,只有輸入相同的明文數據經過相同的消息摘要算法才能得到相同的密文。消息摘要算法不存在密鑰的管理與分發問題,適合於分佈式網絡上使用。消息摘要算法主要應用在 “數字簽名” 領域,作為對明文的摘要算法。著名的摘要算法有 RSA 公司的 MD5 算法和 SHA-1 算法及其大量的變體。

1.1 消息摘要算法的特點

  • 無論輸入的消息有多長,計算出來的消息摘要的長度總是固定的。 例如應用 MD5 算法摘要的消息有 128 個比特位,用 SHA-1 算法摘要的消息最終有 160 個比特位的輸出,SHA-1的變體可以產生 192 個比特位和 256 個比特位的消息摘要。一般認為,摘要的最終輸出越長,該摘要算法就越安全。
  • 消息摘要看起來是 “隨機的”。 這些比特看上去是胡亂的雜湊在一起的,可以用大量的輸入來檢驗其輸出是否相同,一般,不同的輸入會有不同的輸出,而且輸出的摘要消息可以通過隨機性檢驗。 一般地,只要輸入的消息不同,對其進行摘要以後產生的摘要消息也必不相同;但相同的輸入必會產生相同的輸出。
  • 消息摘要函數是單向函數,即只能進行正向的信息摘要,而無法從摘要中恢復出任何的消息,甚至根本就找不到任何與原信息相關的信息。
  • 好的摘要算法,沒有人能從中找到 “碰撞” 或者說極度難找到,雖然 “碰撞” 是肯定存在的(碰撞即不同的內容產生相同的摘要)。

二、什麼是 MD5 算法

MD5(Message Digest Algorithm 5,消息摘要算法版本5),它由 MD2、MD3、MD4 發展而來,由 Ron Rivest(RSA 公司)在 1992 年提出,目前被廣泛應用於數據完整性校驗、數據(消息)摘要、數據簽名等。 MD2、MD4、MD5 都產生 16 字節(128 位)的校驗值,一般用 32 位十六進制數表示。MD2 的算法較慢但相對安全,MD4 速度很快,但安全性下降,MD5 比 MD4 更安全、速度更快。

隨著計算機技術的發展和計算水平的不斷提高,MD5 算法暴露出來的漏洞也越來越多。1996 年後被證實存在弱點,可以被加以破解,對於需要高度安全性的數據,專家一般建議改用其他算法,如 SHA-2。2004 年,證實 MD5 算法無法防止碰撞(collision),因此不適用於安全性認證,如 SSL 公開密鑰認證或是數字簽名等用途。

2.1 MD5 特點

  • 穩定、運算速度快。
  • 壓縮性:輸入任意長度的數據,輸出長度固定(128 比特位)。
  • 運算不可逆:已知運算結果的情況下,無法通過通過逆運算得到原始字符串。
  • 高度離散:輸入的微小變化,可導致運算結果差異巨大。

2.2 MD5 散列

128 位的 MD5 散列在大多數情況下會被表示為 32 位十六進制數字。以下是一個 43 位長的僅 ASCII 字母列的MD5 散列:

MD5("The quick brown fox jumps over the lazy dog")= 9e107d9d372bb6826bd81d3542a419d6

即使在原文中作一個小變化(比如把 dog 改為 cog,只改變一個字符)其散列也會發生巨大的變化:

MD5("The quick brown fox jumps over the lazy cog")= 1055d3e698d289f2af8663725127bd4b

接著我們再來舉幾個 MD5 散列的例子:

         MD5("") -> d41d8cd98f00b204e9800998ecf8427e MD5("semlinker") -> 688881f1c8aa6ffd3fcec471e0391e4d   MD5("kakuqo") -> e18c3c4dd05aef020946e6afbf9e04ef

三、MD5 算法的用途

3.1 防止被篡改

3.1.1 文件分發防篡改

在互聯網上分發軟件安裝包時,出於安全性考慮,為了防止軟件被篡改,比如在軟件安裝程序中添加木馬程序。軟件開發者通常會使用消息摘要算法,比如 MD5 算法產生一個與文件匹配的數字指紋,這樣接收者在接收到文件後,就可以利用一些現成的工具來檢查文件完整性。

一文讀懂 MD5 算法

(圖片來源 —— https://en.wikipedia.org/wiki/MD5)

這裡我們來舉一個實際的例子,下圖是 MySQL Community Server 8.0.19 版本的下載頁,該下載頁通過 MD5 算法分別計算出不同軟件包的數字指紋,具體如下圖所示:

一文讀懂 MD5 算法

(圖片來源 —— https://dev.mysql.com/downloads/mysql/)

當用戶從官網上下載到對應的安裝包之後,可以利用一些 MD5 校驗工具對已下載的文件進行校驗,然後比對最終的 MD5 數字指紋,若結果與官網公佈的數字指紋一致,則表示該安裝包未經過任何修改是安全的,基本可以放心安裝。

3.1.2 消息傳輸防篡改

假設在網絡上你需要發送電子文檔給你的朋友,在文件發送前,先對文檔的內容進行 MD5 運算,得出該電子文檔的 “數字指紋”,並把該 “數字指紋” 隨電子文檔一同發送給對方。當對方接收到電子文檔之後,也使用 MD5 算法對文檔的內容進行哈希運算,在運算完成後也會得到一個對應 “數字指紋”,當該指紋與你所發送文檔的 “數字指紋” 一致時,表示文檔在傳輸過程中未被篡改。

3.2 信息保密

在互聯網初期很多網站在數據庫中以明文的形式存儲用戶的密碼,這存在很大的安全隱患,比如數據庫被黑客入侵,從而導致網站用戶信息的洩露。針對這個問題,一種解決方案是在保存用戶密碼時,不再使用明文,而是使用消息摘要算法,比如 MD5 算法對明文密碼進行哈希運算,然後把運算的結果保存到數據庫中。使用上述方案,避免了在數據庫中以明文方式保存密碼,提高了系統的安全性,不過這種方案並不安全,後面我們會詳細分析。

一文讀懂 MD5 算法

當用戶登錄時,登錄系統對用戶輸入的密碼執行 MD5 哈希運算,然後再使用用戶 ID 和密碼對應的 MD5 “數字指紋” 進行用戶認證。若認證通過,則當前的用戶可以正常登錄系統。用戶密碼經過 MD5 哈希運算後存儲的方案至少有兩個好處:

  • 防內部攻擊:因為在數據庫中不會以明文的方式保存密碼,因此可以避免系統中用戶的密碼被具有系統管理員權限的人員知道。
  • 防外部攻擊:網站數據庫被黑客入侵,黑客只能獲取經過 MD5 運算後的密碼,而不是用戶的明文密碼。

四、MD5 算法使用示例

4.1 Java 示例

在 Java 中使用 MD5 算法很方便,可以直接使用 JDK 自帶的 MD5 實現,也可以使用第三方庫提供的 MD5 實現。下面我們將介紹 JDK、Bouncy Castle 和 Guava 的 MD5 使用示例。為了保證以下示例的正常運行,首先我們需要在 pom.xml 文件中添加 Bouncy Castle 和 Guava 的座標:

     org.bouncycastle     bcprov-jdk15on 
1.64
com.google.guava guava 27.1-jre

JDK 實現

public static void jdkMD5(String src) throws NoSuchAlgorithmException {    MessageDigest md = MessageDigest.getInstance("MD5");    byte[] md5Bytes = md.digest(src.getBytes());    System.out.println("JDK MD5:" + src + " -> " + bytesToHexString(md5Bytes));}

Bouncy Castle 實現

public static void bcMD5(String src) {    MD5Digest digest = new MD5Digest();    digest.update(src.getBytes(), 0, src.getBytes().length);    byte[] md5Bytes = new byte[digest.getDigestSize()];    digest.doFinal(md5Bytes, 0);    System.out.println("Bouncy Castle MD5:" + src + " -> " +       bytesToHexString(md5Bytes));}

Guava 實現

public static void guavaMD5(String src) {    HashFunction hf = Hashing.md5();    HashCode hc = hf.newHasher().putString(src, Charset.defaultCharset()).hash();    System.out.println("Guava MD5:" + src + " -> " + hc);}

在 JDK 實現和 Bouncy Castle 實現的示例中使用了 bytesToHexString 方法,該方法用於把字節數組轉換成十六進制,它的具體實現如下:

private static String bytesToHexString(byte[] src) {    StringBuilder stringBuilder = new StringBuilder();    if (src == null || src.length <= 0) {         return null;    }    for (int i = 0; i < src.length; i++) {        int v = src[i] & 0xFF;        String hv = Integer.toHexString(v);        if (hv.length() < 2) {            stringBuilder.append(0);        }        stringBuilder.append(hv);    }    return stringBuilder.toString();}

介紹完 MD5 算法不同的實現,下面我們來測試一下上述的方法:

public static void main(String[] args) throws NoSuchAlgorithmException {   jdkMD5("123");   bcMD5("123");   guavaMD5("123");} 

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

JDK MD5:123 -> 202cb962ac59075b964b07152d234b70Bouncy Castle MD5:123 -> 202cb962ac59075b964b07152d234b70Guava MD5:123 -> 202cb962ac59075b964b07152d234b70

4.2 Node.js 示例

在 Node.js 環境中,我們可以使用 crypto 原生模塊提供的 md5 實現,當然也可以使用主流的 MD5 第三方庫,比如 md5 這個可以同時運行在服務端和客戶端的第三方庫。與 Java 示例一樣,在介紹具體使用前,我們需要提前安裝 md5 這個第三方庫,具體安裝方式如下:

$ npm install md5 --save

Node.js Crypto 實現

const crypto = require('crypto'); const msg = "123";function md5(data){  const hash = crypto.createHash('md5');  return hash.update(data).digest('hex');}console.log("Node.js Crypto MD5:" + msg + " -> " + md5(msg));

Node.js MD5 第三方庫實現

const md5 = require('md5');const msg = "123";console.log("MD5 Lib MD5:" + msg + " -> " + md5(msg));

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

Node.js Crypto MD5:123 -> 202cb962ac59075b964b07152d234b70MD5 Lib MD5:123 -> 202cb962ac59075b964b07152d234b70

五、MD5 算法的缺陷

哈希碰撞是指不同的輸入卻產生了相同的輸出,好的哈希算法,應該沒有人能從中找到 “碰撞” 或者說極度難找到,雖然 “碰撞” 是肯定存在的。

2005 年山東大學的王小云教授發佈算法可以輕易構造 MD5 碰撞實例,此後 2007 年,有國外學者在王小云教授算法的基礎上,提出了更進一步的 MD5 前綴碰撞構造算法 “chosen prefix collision”,此後還有專家陸續提供了MD5 碰撞構造的開源的庫。

2009 年,中國科學院的謝濤和馮登國僅用了 220.96 的碰撞算法複雜度,破解了 MD5 的碰撞抵抗,該攻擊在普通計算機上運行只需要數秒鐘。

MD5 碰撞很容易構造,基於 MD5 來驗證數據完整性已不可靠,考慮到近期谷歌已成功構造了 SHA-1(英語:Secure Hash Algorithm 1,中文名:安全散列算法1)的碰撞實例,對於數據完整性,應使用 SHA256 或更強的算法代替。

下面我們來看個簡單的 MD5 碰撞示例:

HEX(十六進制)樣本A1

4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2

HEX(十六進制)樣本A2

4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2

兩個樣本之間的差異如下圖所示:

一文讀懂 MD5 算法

下面我們來通過 Java 代碼實際驗證一下樣本 A1 和樣本 A2 經過 MD5 運算後輸出的結果是否一致:

jdkMd5Hex 方法

public static void jdkMd5Hex(String hexStr) throws NoSuchAlgorithmException {    byte[] bytes = hexStringToBytes(hexStr);    MessageDigest md = MessageDigest.getInstance("MD5");    byte[] md5Bytes = md.digest(bytes);    System.out.println("JDK MD5:" + hexStr + " -> " + bytesToHexString(md5Bytes));}

hexStringToBytes 方法

public static byte[] hexStringToBytes(String s) {    int len = s.length();    byte[] data = new byte[len / 2];    for (int i = 0; i < len; i += 2) {        data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4)          + Character.digit(s.charAt(i+1), 16));    }    return data;}

main 方法

public static void main(String[] args) throws NoSuchAlgorithmException {    jdkMd5Hex("4dc968ff..."); //樣本A    jdkMd5Hex("4dc968ff..."); //樣本B}

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

JDK MD5:4dc968ff... -> 008ee33a9d58b51cfeb425b0959121c9JDK MD5:4dc968ff... -> 008ee33a9d58b51cfeb425b0959121c9

如果你對其它 MD5 碰撞的樣本感興趣,可以查看 MD5碰撞的一些例子 這篇文章。由於基於 MD5 來驗證數據完整性已不可靠,因此很多人都熟悉的 Node.js 使用了 SHA256 算法來確保數據的完整性。

一文讀懂 MD5 算法

(圖片來源 —— https://nodejs.org/dist/v12.14.1/SHASUMS256.txt.asc)

六、MD5 密碼安全性

6.1 MD5 密文反向查詢

前面我們已經提到通過對用戶密碼進行 MD5 運算可以提高系統的安全性。但實際上,這樣的安全性還是不高。為什麼呢?因為只要輸入相同就會產生相同的輸出。接下來我們來舉一個示例,字符串 123456789 是一個很常用的密碼,它經過 MD5 運算後會生成一個對應的哈希值:

MD5("123456789") -> 25f9e794323b453885f5181f1b624d0b

由於輸入相同就會產生相同的結果,因此攻擊者就可以根據哈希結果反推輸入。其中一種常見的破解方式就是使用彩虹表。 彩虹表是一個用於加密散列函數逆運算的預先計算好的表,常用於破解加密過的密碼散列。 查找表常常用於包含有限字符固定長度純文本密碼的加密。 這是以空間換時間的典型實踐,在每一次嘗試都計算的暴力破解中使用更少的計算能力和更多的儲存空間,但卻比簡單的每個輸入一條散列的翻查表使用更少的儲存空間和更多的計算性能。

目前網上某些站點,比如 cmd5.com 已經為我們提供了 MD5 密文的反向查詢服務,我們以 MD5("123456789") 生成的結果,做個簡單的驗證,具體如下圖所示:

一文讀懂 MD5 算法

因為 123456789 是很常見的密碼,因此該網站能夠反向得出正確結果那就不足為奇了。以下是 cmd5 網站的站點說明,大家可以參考一下,感興趣的小夥伴可以親自驗證一下。

本站針對 md5、sha1 等全球通用公開的加密算法進行反向查詢,通過窮舉字符組合的方式,創建了明文密文對應查詢數據庫,創建的記錄約 90 萬億條,佔用硬盤超過 500 TB,查詢成功率 95% 以上,很多複雜密文只有本站才可查詢。已穩定運行十餘年,國內外享有盛譽。

現在我們已經知道如果用戶的密碼相同 MD5 的值就會一樣,通過一些 MD5 密文的反向查詢網站,密碼大概率會被解析出來,這樣使用相同密碼的用戶就會收到影響。那麼該問題如何解決呢?答案是密碼加鹽。

6.2 密碼加鹽

鹽(Salt),在密碼學中,是指在散列之前將散列內容(例如:密碼)的任意固定位置插入特定的字符串。這個在散列中加入字符串的方式稱為 “加鹽”。其作用是讓加鹽後的散列結果和沒有加鹽的結果不相同,在不同的應用情景中,這個處理可以增加額外的安全性。

在大部分情況,鹽是不需要保密的。鹽可以是隨機產生的字符串,其插入的位置可以也是隨意而定。如果這個散列結果在將來需要進行驗證(例如:驗證用戶輸入的密碼),則需要將已使用的鹽記錄下來。為了便於理解,我們來舉個簡單的示例。

Node.js MD5 加鹽示例

const crypto = require("crypto");function cryptPwd(password, salt) {  const saltPassword = password + ":" + salt;  console.log("原始密碼:%s", password);  console.log("加鹽後的密碼:%s", saltPassword);  const md5 = crypto.createHash("md5");  const result = md5.update(saltPassword).digest("hex");  console.log("加鹽密碼的md5值:%s", result);}cryptPwd("123456789","exe");cryptPwd("123456789","eft");

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

原始密碼:123456789加鹽後的密碼:123456789:exe加鹽密碼的md5值:3328003d9f786897e0749f349af490ca原始密碼:123456789加鹽後的密碼:123456789:eft加鹽密碼的md5值:3c45dd21ba03e8216d56dce8fe5ebabf

通過觀察以上結果,我們發現原始密碼一致,但使用的鹽值不一樣,最終生成的 MD5 哈希值差異也比較大。此外為了提高破解的難度,我們可以隨機生成鹽值並且提高鹽值的長度。

6.3 bcrypt

哈希加鹽的方式確實能夠增加攻擊者的成本,但是今天來看還遠遠不夠,我們需要一種更加安全的方式來存儲用戶的密碼,這也就是今天被廣泛使用的 bcrypt 。

bcrypt 是一個由 Niels Provos 以及 David Mazières 根據 Blowfish 加密算法所設計的密碼散列函數,於 1999 年在 USENIX 中展示。 bcrypt 這一算法就是為哈希密碼而專門設計的,所以它是一個執行相對較慢的算法,這也就能夠減少攻擊者每秒能夠處理的密碼數量,從而避免攻擊者的字典攻擊。 實現中 bcrypt 會使用一個加鹽的流程以防禦彩虹表攻擊,同時 bcrypt 還是適應性函數,它可以藉由增加迭代之次數來抵禦日益增進的電腦運算能力透過暴力法破解。

由 bcrypt 加密的文件可在所有支持的操作系統和處理器上進行轉移。它的口令必須是 8 至 56 個字符,並將在內部被轉化為 448 位的密鑰。然而,所提供的所有字符都具有十分重要的意義。密碼越強大,您的數據就越安全。

下面我們以 Node.js 平臺的 bcryptjs 為例,介紹一下如何使用 bcrypt 算法來處理用戶密碼。首先我們需要先安裝 bcryptjs :

$ npm install bcryptjs --save

Node.js bcryptjs 處理密碼

const bcrypt = require("bcryptjs");const password = "123456789";const saltRounds = 10;async function bcryptHash(str, saltRounds) {  let hashedResult;  try {    const salt = await bcrypt.genSalt(saltRounds);    hashedResult = await bcrypt.hash(str, salt);  } catch (error) {    throw error;  }  return hashedResult;}bcryptHash(password, saltRounds).then(console.log);

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm

很明顯密碼 123456789 經過 bcrypt 的哈希運算後,得到了一串讀不懂的 “亂碼”。這裡我們已經完成第一步,即用戶登錄密碼的加密。下一步我們要實現登錄密碼的比對,即要保證用戶輸入正確的密碼後,能正常登錄系統。

Node.js bcryptjs 密碼校驗

async function bcryptCompare(str, hashed) {  let isMatch;  try {    isMatc = await bcrypt.compare(str, hashed);  } catch (error) {    throw error;  }  return isMatch;}bcryptCompare(  "123456789",  "$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm").then(console.log);bcryptCompare(  "123456",  "$2a$10$O1SrEy3KsgN0NQdQjaSU6OxjxDo0jf.j/e2goSwSEu4esz9i58dRm").then(console.log);

以上示例代碼正常運行後,在控制檯中會輸出以下結果:

truefalse

因為我們的原始密碼是 123456789 ,很明顯與 123456 並不匹配,所以會輸出以上的匹配結果。

七、總結

本文首先介紹了消息摘要算法、MD5 算法的相關概念和特點,然後詳細介紹了 MD5 算法的用途和 Java 和 Node.js 平臺的使用示例,最後我們還分析了 MD5 算法存在的缺陷和 MD5 密碼的安全性問題。這裡大家需要注意,由於 MD5 碰撞很容易構造,基於 MD5 來驗證數據完整性已不可靠,考慮到近期谷歌已成功構造了 SHA-1(英語:Secure Hash Algorithm 1,中文名:安全散列算法1)的碰撞實例,對於數據完整性,應使用 SHA256 或更強的算法代替。

除了文中介紹的 MD5 應用場景,MD5 還可以用於實現 CDN (Content Delivery Network,內容分發網絡) 內容資源的防盜鏈,感興趣的小夥伴可以閱讀 深入瞭解 Token 防盜鏈 這篇文章。


分享到:


相關文章: