隐写术实战:在图像中隐藏数据的编程技巧

本文详细介绍了如何使用Python和PIL库在图像的最低有效位中隐藏数据,包括将文本转换为二进制、嵌入数据到像素以及提取隐藏消息的具体代码实现和原理分析。

隐写术:在其他事物中隐藏事物的艺术与科学 - 第二部分

第二部分:在图像中隐藏数据

Dakota Nelson* //

在第一部分中,我们讨论了比特如何构成图像,以及这对我们的数字捉迷藏游戏意味着什么。在这篇文章中,我们将利用新的藏身之处来隐藏东西,就像人们通常做的那样。

既然我们知道在哪里隐藏,我们如何实际利用这些知识呢?当然是编程!

首先,我们需要一些东西来隐藏。我将更具争议性的部分留给你,只使用这段Python代码片段,它将一些文本转换为比特列表:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# 让我们设置消息
message = list('this is a message')

# 转换为二进制表示
message = ['{:07b}'.format(ord(x)) for x in message]
print("Message as binary:")
print(message)

# 将二进制拆分为比特
message = [[bit for bit in x] for x in message]

# 展平并转换为整数
message = [int(bit) for sublist in message for bit in sublist]
print("Message as list of bits:")
print(message)

这段代码的最终输出应该是一个看起来像这样的消息:

1
2
3
4
5
[1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 
1, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 0, 0, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 0, 
0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, 0, 1, 1, 
1, 0, 0, 1, 0, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 0, 0, 0, 0, 
1, 1, 1, 0, 0, 1, 1, 1, 1, 1, 0, 0, 1, 0, 1]

这是短语“this is a message”的二进制形式。哇!我们有东西要隐藏了!

现在我们必须将这条消息放入这张图片中,将其隐藏在图像的最低有效位中。

我们将使用这段代码片段,它打开一个现有图像并将消息添加到其中,将消息中的每个比特重复九次,原因稍后会变得清楚:

 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
from PIL import Image, ImageFilter
import numpy as np

# 首先,打开原始图像
imgpath = 'images/original/image.bmp'
img = Image.open(imgpath)

# 我们将使用简单的重复作为非常初级的纠错代码,以尝试保持完整性
# 消息的每个比特将重复9次 - 一个像素的R、G和B值的三个最低有效位
imgArray = list(np.asarray(img))

def set_bit(val, bitNo, bit):
    """给定一个值,要设置的位号,以及要设置的实际位(0或1),返回具有适当位翻转的新值"""
    mask = 1 << bitNo
    val &= ~mask
    if bit:
        val |= mask
    return val

msgIndex = 0
newImg = []
# 这部分代码将每个像素中R、G和B值的最低有效3位设置为来自我们消息的一个比特
# 这意味着我们消息的每个比特被重复9次 - 在R、G和B中各3次。从技术上讲,这是一种浪费
# 但这是需要的,以防我们在传输过程中丢失一些数据
# 使用最后3位而不是最后2位意味着图像在视觉上看起来稍差,但我们可以在其中存储更多数据 - 这是一种权衡
# 比特越重要,它们被压缩改变的可能性就越小 - 理论上,我们可以将数据隐藏在消息的最高有效位中
# 它们可能永远不会被压缩等改变,但看起来会很糟糕,这违背了整个目的
for row in imgArray:
    newRow = []
    for pixel in row:
        newPixel = []
        for val in pixel:
            # 逐个遍历RGB值
            if msgIndex >= len(message):
                # 如果我们没有更多的消息要放入图像中,只添加零
                setTo = 0
            else:
                # 从消息中获取另一个比特
                setTo = message[msgIndex]
            # 将此R、G或B像素的最后3位设置为我们决定的值
            val = set_bit(val, 0, setTo)
            val = set_bit(val, 1, setTo)
            val = set_bit(val, 2, setTo)
                
            # 继续构建我们的新图像(现在带有100%的隐藏消息!)
            newPixel.append(val) # 这将一个R、G或B值添加到像素中
        # 开始查看消息中的下一个比特
        msgIndex += 1
        newRow.append(newPixel) # 这将一个像素添加到行中
    newImg.append(newRow) # 这将一行添加到我们的图像数组中

arr = np.array(newImg, np.uint8) # 将我们的新图像转换为numpy数组
im = Image.fromarray(arr)
im.save("image_steg.bmp")

你可能想知道…为什么我们要如此重复消息?每个比特重复九次似乎过分了。

事实证明,我们并不是唯一注意到图像中最低有效位基本上是随机的人。有人已经抢先一步使用了我们的藏身之处,他们用它来做无聊的事情。

根据维基百科,压缩的目标是“减少图像数据的无关性和冗余性,以便能够以高效的形式存储或传输数据。”

但那些“无关和冗余的数据”正是我们想要放置我们狡猾消息的地方,而压缩会破坏这些比特。糟糕。事实证明,如果有无用的比特,比如每个像素值的最低有效位,它们非常适合隐藏东西,因为没有人关心它们,但也是第一个被压缩丢弃的…因为没有人关心它们。

所以我们反击,通过大量重复自己,这样即使一些比特被压缩翻转,我们的数据仍然大部分能够通过。这不优雅,但有效。(这将在第三部分中更好地解释,我们将使用一些很酷的数学方法进入更优雅的方法。)

一旦我们通过代码运行图像,它看起来像这样:

这可能看起来很熟悉 - 现在我们知道这只小狗从第一部分隐藏的消息了!但是…一旦它被放进去,我们如何把它取出来呢?

方法如下:

 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
# 打开图像并提取我们的最低有效位,看看消息是否通过
img = Image.open(path)
imgArray = list(np.asarray(img))

# 注意消息仍然必须从上面的代码块设置
#(或者你可以在这里重新创建它)
origMessage = message[:20] # 取原始消息的前20个字符
# 我们不在这里使用整个消息,因为我们只想确保它通过了
print("Original message:")
print(origMessage)

message = []

for row in imgArray:
    for pixel in row:
        # 我们将计算我们看到多少“0”或“1”值,然后选择
        # 得票最高的结果(希望我们有足够的重复!)
        count = {"0": 0, "1": 0}
        for val in pixel:
            # 逐个遍历像素的RGB值
            # 将R、G或B值转换为字节字符串
            byte = '{:08b}'.format(val)
            # 然后,对于每个值中的最低有效3位...
            for i in [-1, -2, -3]:
                # 尝试从中获取实际的1或0整数
                try:
                    bit = int(byte[i])
                except:
                    # 如果,不知何故,字节的最后部分不是整数...?
                    #(这不应该发生)
                    print(bin(val))
                    raise
    
                # 计算我们看到的比特
                if bit == 0:
                    count["0"] += 1
                elif bit == 1:
                    count["1"] += 1
                else:
                    print("WAT")
                    
        # 一旦我们看到了所有,决定我们应该选择哪个
        # 希望如果压缩(或任何东西)翻转了其中一些比特,
        # 它翻转的足够少,以至于大多数仍然是准确的
        if count["1"] > count["0"]:
            message.append(1)
        else:
            message.append(0)

# 即使我们提取了完整的消息,我们仍然只显示
# 前20个字符,只是为了确保它们符合我们的期望
print("Extracted message:")            
print(message[:20])

在图像上运行这个,你得到原始消息和新提取消息的前20个字符:

1
2
3
4
5
Original message:
[1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0]

Extracted message:
[1, 1, 1, 0, 1, 0, 0, 1, 1, 0, 1, 0, 0, 0, 1, 1, 0, 1, 0, 0]

太棒了!它们是一样的!我们刚刚使用隐写术在图像中隐藏了数据!(自己尝试一下:你能通过反转之前的过程将这些比特重新组装成文本吗?)

能够从图像中提取隐写编码的数据很酷,但不得不如此重复自己意味着我们不能移动很多数据,而且它相当明显 - 带有隐藏数据的图像与原始图像看起来足够不同,如果你仔细观察,你可以看出有些不对劲。这张图像是500乘500像素,这意味着(因为我们只能每个像素隐藏一个数据比特)我们只能在这张图像中隐藏刚刚超过31 kB的数据。这很好,也很有用,但你需要很多图片来发送任何显著数量的数据 - 特别是因为我们使用的是图像中最低有效的3位,而我们更愿意使用更少,这样图像看起来就不会有任何不同。在第三部分中,我们将探索如何使用更复杂的纠错代码使我们的数据隐藏更高效。

特别感谢Zoher Ghadyali和Philip Seger多年前合作编写了这些代码片段的原始版本。


*Dakota运营Striker Security - 你可以在https://strikersecurity.com/blog找到更多他的文章

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计