base64 編碼不唯一的問題


個人筆記,需要前置知識——Base64 編碼原理。

問題

今天測試 JWT,發現修改 JWT 的最后一個字符(其實不是我發現的。。),居然有可能不影響 JWT 的正確性。比如如下這個使用 HS256 算法的 JWT:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

把它的最后一個字符改成 d e或者 f,都能成功通過 http://jwt.io 的驗證。

這讓我覺得很奇怪(難道我發現了一個 Bug?),在QQ群里一問,就有大佬找到根本原因:這是 base64 編碼的特性。並且通過 python 進行了實際演示:

In [1]: import base64

# 使用 jwt 的 signature 進行驗證
In [2]: base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c==")
Out[2]: b'I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%V\x9dB\xcb0\xe5'

In [3]: base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5d==")
Out[3]: b'I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%V\x9dB\xcb0\xe5'

In [4]: base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5e==")
Out[4]: b'I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%V\x9dB\xcb0\xe5'

In [5]: base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5f==")
Out[5]: b'I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%V\x9dB\xcb0\xe5'

# 兩個等於號之后的任何內容,都會被直接丟棄。這個是實現相關的,有的 base64 處理庫對這種情況會報錯。
In [6]: base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5f==fdf=df==dfd=fderwe=r")
Out[6]: b'I\xf9J\xc7\x04IH\xc7\x8a(]\x90O\x87\xf0\xa4\xc7\x89\x7f~\x8f:N\xb2%V\x9dB\xcb0\xe5'

可以看到將最后一個字符(不考慮 ==)改成 d e f,解碼出來的都是同樣的內容。

原因分析

base64 編碼將二進制內容(bytes)從左往右每 6 bits 分為一組,每一組編碼為一個可打印字符。
bas64 從 ASCII 字符集中選出了 64 個字符(=號除外)進行編碼。因為 \(2^6=64\),使用 64 個字符才能保證上述編碼的唯一性。

但是被編碼的二進制內容(bytes)的 bits 數不一定是 6 的倍數,無法被編碼為 6 bits 一組。
為了解決這個問題,就需要在這些二進制內容的末尾填充上 2 或 4 個 bit 位,這樣才能使用 base64 進行編碼。

關於這些被填充的 bits,在 RFC4648 中定義了規范行為:全部補 0.
但是這並不是一個強制的行為,因此實際上你可以隨便補,在進行 base64 解析時,被填補的 bits 會被直接忽略掉。

這就導致了上面描述的行為:修改 JWT 的最后一個字符(6 bits,其中可能包含 2 或 4 個填充比特位)可能並不影響被編碼的實際內容!

RFC4684 中對這個 bits 填充的描述如下:

   3.5.  Canonical Encoding

   The padding step in base 64 and base 32 encoding can, if improperly
   implemented, lead to non-significant alterations of the encoded data.
   For example, if the input is only one octet for a base 64 encoding,
   then all six bits of the first symbol are used, but only the first
   two bits of the next symbol are used.  These pad bits MUST be set to
   zero by conforming encoders, which is described in the descriptions
   on padding below.  If this property do not hold, there is no
   canonical representation of base-encoded data, and multiple base-
   encoded strings can be decoded to the same binary data.  If this
   property (and others discussed in this document) holds, a canonical
   encoding is guaranteed.

   In some environments, the alteration is critical and therefore
   decoders MAY chose to reject an encoding if the pad bits have not
   been set to zero.  The specification referring to this may mandate a
   specific behaviour.

它講到在某些環境下,base64 解析器可能會嚴格檢查被填充的這幾個 bits,要求它們全部為 0.
但是我測試發現,Python 標准庫和 https://jwt.io 都沒有做這樣的限制。因此我認為絕大部分環境下,被填充的 bits 都是會被忽略的。

問題一:為什么只需要填充 2 或 4 個 bit 位?

這是看到「填充上 2 或 4 個 bit 位」時的第一想法——如果要補足到 6 的倍數,不應該是要填充 1-5 個 bit 位么?

要解答這個問題,我們得看 base64 的定義。在 RFC4648 的 base64 定義中,有如下這樣一段話:

The Base 64 encoding is designed to represent arbitrary sequences of
octets in a form that allows the use of both upper- and lowercase
letters but that need not be human readable.

注意重點:octets—— 和 bytes 同義,表示 8 bits 一組的位序列。這表示 base64 只支持編碼 bits 數為 8 的倍數的二進制內容,而 \(8x \bmod 6\) 的結果只可能是 0/2/4 三種情況。

因此只需要填充 2 或 4 個 bit 位。

這樣的假設也並沒有什么問題,因為現代計算機都是統一使用 8 bits(byte) 為最小的可讀單位的。即使是 c 語言的「位域」也是如此。
因為 Byte(8 bits) 現代 CPU 數據讀寫操作的基本單位,學過匯編的對這個應該都有些印象。

你仔細想想,所有文件的最小計量單位,是不是都是 byte?

問題二:為什么用 python 測試時可能需要在 JWT signature 的末尾添加多個 =,而 JWT 中不需要?

前面已經講過,base64 的編碼步驟是是將字節(byte, 8 bits)序列,從左往右每 6 個 bits 轉換成一個可打印字符。

查閱 RFC4648 第 4 小節中 baae64 的定義,能看到它實際上是每次處理 24 bits,因為這是 6 和 8 的最小公倍數,可以剛好用 4 個字符表示。
在被處理的字節序列的比特(bits)數不是 24 的整數時,就需要在序列末尾填充 0 使末尾的 bits 數是 6 的倍數(6-bit groups)。有可能會出現三種情況:

  1. 被處理的字節序列 S 的比特數剛好是 24 的倍數:不需要補比特位,末尾也就不需要加 =
  2. S 的比特數是 \(24x+8\): 末尾需要補 4 個 bits,這樣末尾剩余的 bits 才是 6-bit groups,才能編碼成 base64。然后添加兩個 == 使編碼后的字符數為 4 的倍數。
  3. S 的比特數為 \(24x+16\):末尾需要添加 2 個 bits 才能編碼成 base64。然后添加一個 = 使編碼后的字符數為 4 的倍數。

其實可以看到,添加 = 的目的只是為了使編碼后的字符數為 4 的倍數而已,= 這個 padding 其實是冗余信息,完全可以去掉。

在解碼完成后,應用程序會自動去除掉末尾這不足 1 byte 的 2 或 4 個填充位。

因此 JWT 就去掉了它以減少傳輸的數據量。

可以用前面講到的 JWT signature 進行驗證:

In [1]: import base64

In [2]: s = base64.b64decode("SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c==")

# len(s) * 8 得到 bits 數
In [3]: len(s) * 8 % 24
Out[3]: 8

可以看到這里的被編碼內容比特數為 \(24x+8\),所以末尾需要添加兩個 == 號才符合 RFC4648 的定義。

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM