假设你想向朋友发送一条秘密消息,但你想使用的频道已被破解并受到监控。你可以使用某种加密,但这会引起监控你对话的人的怀疑,所以你必须使用其他方法。
如今,隐写术是一种将秘密信息隐藏在另一个非秘密文件(如猫的图片)中的方法,这样如果您发送该文件,就不会被发现。隐写术不仅限于将文本隐藏在图像中,通常意味着“将秘密信息隐藏在另一个非秘密信息或物理对象中”:您可以使用例如列转置将某些消息隐藏在音频、视频或其他文本中。
隐写术在许多其他情况下也非常有用,例如,它可以作为敏感文件中水印的良好替代品,以防止泄漏。
隐藏图像中信息的方法有很多,从简单地将文本附加到文件末尾到将其隐藏在元数据中。在本文中,我想介绍一种更高级的隐写术,深入到二进制级别并将消息隐藏在图像本身的边界内。
对于我的隐写术示例,我决定使用 JavaScript,因为它是一种可以在浏览器中执行的强大的编程语言。
我想出了一个简单的界面,允许用户上传图像来阅读其中隐藏的信息,或者自己在图像中编码信息。
<html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Steganography</title> <style> body { font-family: Arial, sans-serif; text-align: center; margin-top: 50px; } textarea { width: 300px; height: 100px; } button { margin: 10px; } #outputImage { margin-top: 20px; max-width: 100%; } </style> </head> <body> <h1>Steganography Example</h1> <input type="file" id="upload" accept="image/*"><br> <canvas id="canvas" style="display:none;"></canvas><br> <textarea id="text" placeholder="Enter text to encode"></textarea><br> <button id="encode">Encode Text</button> <button id="decode">Decode Text</button> <p id="decodedText"></p> <img id="outputImage" alt="Output Image"> <script src="./script.js"></script> </body> </html>
要使用它,用户只需选择他们想要处理的图像,然后尝试从中解码一些文本,或者对其进行编码并稍后下载图像。
要在 JavaScript 中处理图像,我们可以使用Canvas API 。它提供了许多不同的函数来处理和绘制图像、动画,甚至游戏图形和视频。
Canvas API 主要用于 2D 图形。如果您想要处理 3D 硬件加速图形,则可以使用WebGL API (顺便说一下,它也使用 <canvas> 元素)。
const canvas = document.getElementById("canvas"); const ctx = canvas.getContext("2d"); const image = new Image();
要从文件系统读取图像文件并将其添加到画布上下文,我们可以使用FileReader API 。它允许我们轻松读取存储在用户计算机上的任何文件的内容,而无需自定义库。
function handleFileUpload(event) { const reader = new FileReader(); reader.onload = function (e) { image.src = e.target.result; image.onload = function () { canvas.width = image.width; canvas.height = image.height; ctx.drawImage(image, 0, 0); }; }; reader.readAsDataURL(event.target.files[0]); }
它读取一个文件并将该文件中的图像绘制到我们之前定义的 2D 画布上下文中,之后,我们可以将一些文本编码到该图像中或尝试从图像中读取文本。
图像由像素组成,每个像素都包含有关其颜色的信息。例如,如果使用 RGBA 模型对图像进行编码,则每个像素将包含 4 个字节的信息,说明它代表了多少红色、绿色、蓝色和 alpha(不透明度)。
要对图像中的文本进行编码,我们可以使用这些通道之一(例如 alpha 通道)。由于此信息以二进制表示(如 01001100),我们可以将最后一位更改为我们需要的任何值。它被称为最低有效位 (LSB),更改它只会对图像本身造成最小的改变,使其与人类难以区分。
现在,假设我们有像“Hello”这样的文本,我们想把它编码成图像。实现这一点的算法是
将“Hello”文本转换为二进制。
遍历图像数据的字节,并用二进制文本中的一位替换这些字节的 LSB(每个像素包含每种颜色的 4 个字节的数据,在我的示例中,我想更改图像的不透明度通道,因此我将对每 4 个字节进行迭代)。
在消息末尾添加一个空字节,以便解码时我们知道何时停止。
将修改后的图像字节应用于图像本身。
首先,我们需要从用户那里获取我们想要编码的文本并对其进行一些基本验证。
const text = document.getElementById("text").value; if (!text) { alert("Please enter some text to encode."); return; }
然后,我们需要将文本转换为二进制,并创建要将该文本编码到的图像的画布。
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; for (let i = 0; i < text.length; i++) { let binaryChar = text.charCodeAt(i).toString(2).padStart(8, "0"); binaryText += binaryChar; }
为此,我们可以简单地遍历每个字符并使用charCodeAt
函数获取 Unicode 索引。然后将此 Unicode 转换为二进制并进行填充,使其长度与任何其他字符相同。
例如,字母“H”在 Unicode 中表示为 72;然后我们将该数字转换为二进制(1001000),并在开头添加 0(01001000)以确保所有字母的长度相同(8 位)。
然后,我们需要在消息末尾添加一个空字节,以确保解密时能够区分真实文本和图像的随机像素数据。
binaryText += "00000000";
然后,我们需要做一些基本的验证,以确保图像有足够的像素来编码我们的消息,以免溢出。
if (binaryText.length > data.length / 4) { alert("Text is too long to encode in this image."); return; }
接下来是最有趣的部分,即消息的编码。我们之前定义的数据数组包含图像中每个像素的 RGBA 值形式的像素信息。因此,如果图像是 RGBA 编码的,则其中的每个像素将由数据数组的 4 个值表示;每个值代表该像素有多少红色、绿色和蓝色。
for (let i = 0; i < binaryText.length; i++) { data[i * 4] = (data[i * 4] & 0b11111110) | parseInt(binaryText[i]); } ctx.putImageData(imgData, 0, 0); const outputImage = document.getElementById("outputImage"); outputImage.src = canvas.toDataURL();
在上面的代码中,我们迭代二进制编码的文本。data data[i * 4]
找到我们需要修改的字节,由于我们只想修改特定通道的字节,因此我们将变量i
乘以4
来访问它。
data[i * 4] & 0b11111110
操作将最低有效位设置为0
。例如,如果data[i * 4]
以二进制表示为10101101
,则10101101 & 11111110
操作的结果为10101100
。这确保在我们对其进行任何进一步操作之前,LSB 已设置为0
。
parseInt(binaryText[i])
是二进制编码字符串的当前位;它要么是1
要么是0
。然后我们可以使用按位或 ( |
) 运算将此位设置为 LSB。例如,如果按位或的左侧部分是10101100
且binaryText[i]
是1
,则10101100 | 00000001
将得到10101101
。如果当前位是0
,则或的结果将为10101100
。这就是我们首先必须删除 LSB 的原因。
一旦消息被编码,我们就可以将其放置在当前画布中,并使用canvas.toDataURL
方法以HTML形式呈现。
解码图像的过程实际上比编码简单得多。由于我们已经知道我们只编码了 alpha 通道,我们可以简单地遍历每 4 个字节,读取最后一位,将其连接到最终字符串中,并将该数据从二进制转换为 Unicode 字符串。
首先,我们需要初始化变量。由于imgData
已经填充了图像信息(每次从文件系统读取文件时我们都会调用ctx.drawImage
),因此我们可以简单地将其提取到数据变量中。
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); const data = imgData.data; let binaryText = ""; let decodedText = "";
然后我们需要遍历图像的每个第 4 个字节,读取最后一位,并将其连接到binaryText
变量。
for (let i = 0; i < data.length; i += 4) { binaryText += (data[i] & 1).toString(); }
data[i]
是编码字节,要提取 LSB,我们可以使用按位 AND ( &
) 运算符。它采用两个值并对每对对应位执行 AND 运算。通过将data[i]
与1
进行比较,我们基本上将最低有效位与像素信息隔离开来,如果 LSB 为1
,则此运算的结果为1
。如果 LSB 为0
,结果也将为0
。
一旦我们读取了所有 LSB 并将它们存储在binaryText
变量中,我们就需要将其从二进制转换为纯文本。由于我们知道每个字符由 8 位组成(还记得我们如何使用padStart(8, "0")
使每个字符的长度相同吗?),我们可以对binaryText
的每个第 8 个字符进行迭代。
然后,我们可以使用.slice()
操作根据迭代从binaryText
中提取当前字节。可以使用parseInt(byte, 2)
函数将二进制字符串转换为数字。然后,我们可以检查结果是否为0
(空字节) - 我们停止转换并查询结果。否则,我们可以找到哪个字符对应于 Unicode 数字并将其添加到我们的结果字符串中。
for (let i = 0; i < binaryText.length; i += 8) { let byte = binaryText.slice(i, i + 8); if (byte.length < 8) break; // Stop if the byte is incomplete let charCode = parseInt(byte, 2); if (charCode === 0) break; // Stop if we hit a null character decodedText += String.fromCharCode(charCode); }
然后可以安全地将解码后的文本显示给用户:
document.getElementById("decodedText").textContent = decodedText;
我已将本文使用的完整代码保留在我的GitHub 存储库中;请随意试用。还有很多地方可以改进 :)
隐写术是一种非常强大的技术,它可以应用于许多不同的用例,从文档验证、泄漏预防、图像 AI 验证、音乐文件 DRM 管理等等。这项技术甚至可以应用于视频、游戏甚至原始文本,所以我认为它具有巨大的潜力。
在 NFT 和区块链时代,看看它将如何找到自己的用例以及这种技术将如何发展就更加有趣了。