Base64编码和隐写

1. Base64 基本介绍

Base64 (基底64)这个术语源自 MIME 的 Content-Transfer-Encoding1,它是一种基于 64 个可打印字符来表示二进制数据的方法。

因为 $\log_2{64}=6$,所以 Base64 编码每 6 个比特位为 1 个单元,换句话说,Base64 编码将每 3 字节(24 位)转换为 4 个 6 位的字符。

Base64 只是进行了编码,方便数据的传输,并不是加密。

Base64 可打印字符包括大写字母 A-Z、小写字母 a-z、数字 0-9,这样就有 62 个字符了,另外两个字符可能略有不同,通常采用 MIME 中的方式,也就是选择斜杆 / 和加号 + ,这样共 64 个字符,并将等号 = 作为后缀填充。

在 URL 中使用标准 Base64 编码时1会将 +/= 字符进行 URL 编码,导致 + 变为 %2B/ 变为 %2F= 变为 %3D,使得字符串变得冗长。为此,有专门针对 URL 的 Base64编码,它将标准 Base64 编码的 +/ 字符分别替换为 -_,从而避免对一些特殊字符进行 URL 编码/解码。

下表为标准 Base64 编码的字符集

Index Binary Char Index Binary Char Index Binary Char Index Binary Char
0 000000 A 16 010000 Q 32 100000 g 48 110000 w
1 000001 B 17 010001 R 33 100001 h 49 110001 x
2 000010 C 18 010010 S 34 100010 i 50 110010 y
3 000011 D 19 010011 T 35 100011 j 51 110011 z
4 000100 E 20 010100 U 36 100100 k 52 110100 0
5 000101 F 21 010101 V 37 100101 l 53 110101 1
6 000110 G 22 010110 W 38 100110 m 54 110110 2
7 000111 H 23 010111 X 39 100111 n 55 110111 3
8 001000 I 24 011000 Y 40 101000 o 56 111000 4
9 001001 J 25 011001 Z 41 101001 p 57 111001 5
10 001010 K 26 011010 a 42 101010 q 58 111010 6
11 001011 L 27 011011 b 43 101011 r 59 111011 7
12 001100 M 28 011100 c 44 101100 s 60 111100 8
13 001101 N 29 011101 d 45 101101 t 61 111101 9
14 001110 O 30 011110 e 46 101110 u 62 111110 +
15 001111 P 31 011111 f 47 101111 v 63 111111 /
Padding =

2. Base64 编码过程

Base64 编码的步骤大致如下:

(1)将每个字符转换成二进制,合并得到一个二进制串,如果长度不能被 6 整除,需要进行填充;

(2)将二进制串按 6 位一组划分,根据 Base64 字符集进行编码;

(3)Base64 编码后的字符长度不是 4 的倍数时,用一个或两个 = 进行填充。

Man 的 Base64 编码结果 TWFu 为例,其转换过程如下所示

在将每 3 个字节转换为 4 个 Base64 字符时,如果要转换的字节数不能被 3 整除,最后会多出 1 个或 2 个字节,这时需要用 0 在末尾补足,使二进制串的长度能够被 6 整除,编码后字符长度不是 4 的倍数的,需要在 Base64 编码后的需要加上一个或两个 = 号,使编码后的长度能被 4 整除。

换句话说,一个 = 表示最后 4 个 Base64 字符(包括 = 在内)解码后可以得到 2 个 8 位的字符,两个 = 表示最后 4 个 Base64 字符(包括 = 在内)解码后可以得到 1 个 8 位的字符。

不存在 3个 = 的情况,因为这种情况下 4 个 Base64 字符中只有 1 个非填充字符,有效位仅有 6 位,不可能由 8 位字符编码而成。

比如下面的例子,二进制串长度为 16,补上 2 位后的长度才能被 6 整除:

再比如下面的例子,二进制串长度为 8,需要补上 4 位后长度才能被 6 整除:

填充不是必须的,因为无需填充也可以通过编码后的内容计算出缺失的字节。所以在一些实现中填充是必须的,有些却不是。一种必须使用填充的场合是当需要将多个 Base64 编码文件合并为一个文件的时候2

3. Base64 解码过程

解码过程如下:

(1)根据 Base64 字符集转变成相应的二进制串,如果存在填充字符 = 需要先去掉;

(2)将二进制串按 8 位一组划分,剩余长度如果不足 8 位,则直接去除;

(3)将每 8 位二进制串转换为相应的 ASCII 字符。

注:标准 ASCII 码使用 7 位二进制数组合来表示 128 个字符。现在许多基于 x86 的系统都支持使用扩展 ASCII。扩展 ASCII 码使用 8 位二进制数表示 256 个字符。

有无填充字符 = 其实都可以进行 Base64 解压。当只有一个填充字符 = 时,下图分别展示了有无填充字符的解码过程:

当只有两个填充字符 = 时,下图分别展示了有无填充字符的解码过程:

4. Base64 隐写

我们已经知道补 Base64 编码过程中可能存在补 0 的操作,那么补的必须是 0 吗,换成1可不可以?当然可以3

因为解码时无论最后补的数是 0 还是 1,都会被删去,修改这些位不会影响解码结果,这便是Base64隐写的原理。

从上文知道,Base64 解压时 8 位一组,不足 8 位的直接去除,所以补的位不影响结果。

我们可以通过修改补 0 位的数据来写入我们想隐写的内容,只不过写入的数据越长,需要的 Base64 编码文本条数就越多。

一个 = 符号代表着我们可以写入 2 位二进制数据,两个 = 可以写入 4 位二进制数据。

在本文 Base64 编码过程Base64 解码过程 这两部分可以体现,编码过程中分别补上了 2 个 0 和 4 个 0,解码时中都去掉了。

正常情况下,将 Base64 字符串解码后得到的文本再次进行 Base64 编码,得到的结果应该是和原 Base64 编码一样的,如果不一样说明证明这段 Base64 编码文本有隐写的内容。

  • Base64 隐写加密脚本如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
import base64
import string

def base64stego_encrypt(line_list, secret):
# MIMIE Base64字符集,注意按规范排列,即A-Za-z0-9+/
b64charset = string.ascii_uppercase + string.ascii_lowercase + string.digits + "+" + "/"
binary_str = ""
stego_list = []
for c in secret:
temp = bin(ord(c))[2:].zfill(8) # [2:]用于去掉0b,左侧补零使每个字符用8个比特位编码
binary_str += temp
offset = 0
for line in line_list:
if offset>=len(binary_str):
break
line = line.strip() # 去换行符
b64str = base64.b64encode(line.encode("ASCII")).decode("ASCII")
pad_nums = b64str.count("=") # 记录填充字符个数
if pad_nums!=0:
c = b64str[-1-pad_nums] # 去掉“=”后取最后一个字符
# 一个=可以隐写2位
bins = binary_str[offset: offset+(pad_nums*2)]
offset += pad_nums*2
# 隐写数据,注意用MIMIE Base64字符集顺序
c_index = b64charset.index(c)
new_c_index = int( bin(c_index)[2:].zfill(8)[:-(pad_nums*2)]+bins, 2)
new_c = b64charset[ new_c_index ]
# 替换 Base64 字符串
b64str = b64str[:-1-pad_nums] + new_c + "=" * pad_nums
stego_list.append(b64str)
return stego_list


with open('plain.txt', 'r') as f:
"""plain.txt
this is line 1.
tthis is line 2.
tthhis is line 3.
tthhiis is line 1.
tthhiiss is line 2.
tthhiiss iis line 3.
tthhiiss iiss line 1.
tthhiiss iiss lline 2.
tthhiiss iiss lliine 3.
tthhiiss iiss lliinne 1.
tthhiiss iiss lliinnee 2.
tthhiiss iiss lliinnee 33.
"""
plain_lines = f.readlines()

secret = "abc"
stego_list = base64stego_encrypt(plain_lines, secret)
with open("stego.txt", "w") as f:
for line in stego_list:
f.write(line + "\n")

隐写的信息越长,用来隐写的文本文件也就要越长,上面的代码展示了隐写字符串 abc 的例子。

经 Base64 隐写后,最终输出的 stego.txt 的内容如下所示

1
2
3
4
5
6
7
8
9
10
11
12
dGhpcyBpcyBsaW5lIDEu
dHRoaXMgaXMgbGluZSAyLm==
dHRoaGlzIGlzIGxpbmUgMy4=
dHRoaGlpcyBpcyBsaW5lIDEu
dHRoaGlpc3MgaXMgbGluZSAyLl==
dHRoaGlpc3MgaWlzIGxpbmUgMy6=
dHRoaGlpc3MgaWlzcyBsaW5lIDEu
dHRoaGlpc3MgaWlzcyBsbGluZSAyLi==
dHRoaGlpc3MgaWlzcyBsbGlpbmUgMy5=
dHRoaGlpc3MgaWlzcyBsbGlpbm5lIDEu
dHRoaGlpc3MgaWlzcyBsbGlpbm5lZSAyLo==
dHRoaGlpc3MgaWlzcyBsbGlpbm5lZSAzMy7=
  • Base64 隐写解密脚本如下
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import base64
import string

def base64stego_decrypt(line_list):
# MIMIE Base64字符集,注意按规范排列,即A-Za-z0-9+/
b64charset = string.ascii_uppercase + string.ascii_lowercase + string.digits + "+" + "/"
binary_str = ""
plain_str = ""
for line in line_list:
line = line.strip() # 去换行符
pad_nums = line.count("=") # 记录填充字符个数
check = base64.b64encode(base64.b64decode(line)).decode("ASCII") # base64解码再编码
if line!=check:
# 去掉=后取最后一个字符
c1, c2 = line[-1-pad_nums], check[-1-pad_nums]
# 计算十进制差值,也就是隐写的数据,注意要用MIMIE Base64字符集顺序
diff = abs(b64charset.index(c1)-b64charset.index(c2))
# 0bxxxx的字符串,[2:]用于去掉0b
# 一个“=”隐写2位数据,两个隐写4位,位数不足时左侧补零
temp = bin(diff)[2:].zfill(pad_nums * 2)
binary_str += temp
else:
temp = "0" * pad_nums * 2 # pad_nums为0的话temp为""
binary_str += temp
for i in range(0, len(binary_str), 8):
plain_str += chr(int(binary_str[i:i+8], 2))
print(plain_str)
return plain_str

with open('stego.txt', 'r') as f:
file_lines = f.readlines()
base64stego_decrypt(file_lines)

参考资料

1. https://en.wikipedia.org/wiki/Base64