MENU

【代码札记】初探图片隐写术(Steganography)之LSB隐写

June 14, 2023 • 瞎折腾

本文介绍如何使用LSB隐写将数据藏匿在图片中。

通常来说,要保护你的数据,最简单的方法就是加密。现代加密算法在正确使用的前提下可以认为是非常安全,除非这地球上所有国家决定不惜一切代价就是要干爆你(话是这么说,如果真是到了这种地步,一颗核弹不比破解先进的加密算法来的划算?)。

但加密算法有一个问题:他会让别人看出来这是加密的。这一点在大多数情况下没什么问题,但假如你的身家性命就寄托在这份数据上,那除了加密,你最好还要把它好好地藏起来。

不止间谍、记者和人权活动家有这种需求,我认为作为一个文明社会的人类个体,每一个人都有正当藏匿数据的权利,每个人的隐私也都值得且应当受到保护,任何其他个人和组织(包括政府)都不应该侵犯这种权利。而为了保护我们的权利,相应的技术也孕育而生。这些技术可能会被滥用于犯罪活动,但我认为从整体而言,隐私权利被侵犯的后果要比一个技术被滥用于犯罪的后果要大得多——任何时候为了预防犯罪而侵犯个人权利都是不恰当的。如果你强烈不认同这一观点,那么这里不欢迎你,请你离开。

总之,和加密不同,隐写术是一种帮助你将数据隐藏在另一种数据中的技术。本篇文章以图片隐写术为主题,即以图片为载体藏匿数据。这其中,LSB隐写是一门非常原始但有效的技术(当然,如果使用得当的话)。

你可能看出来了,任何宣称安全可靠的东西都有一个前提:使用得当。具体来说,不同的加密算法和隐写术有各自的特性和风险。我不是隐写术和安全方面的专家,本文也没有经过同行评议,因为它压根儿就不是论文。如果你决定采用下文中介绍的技巧和方法藏匿数据,你需要自己评估并决定承担其中的风险。

何为LSB?

LSB的全称是Least Significant Bit,翻译过来是最低有效位。什么叫最低有效位呢?比如一个数字12345,那它的最低有效位就是个位(数字5),因为它的改变对于值的影响最小。而相对应地,万位(数字1)是最高有效位,即Most Significant Bit,MSB。在计算机中,所有数据都是以二进制形式表示的,因此LSB通常是指bit 0。

由于LSB的变化对于值的影响很小,而现代表示图像的方法采用RGB三个通道来表示,每个通道有256个可选的值。对于肉眼来说,这个值增大一点或减小一点,几乎难以区分。不信?看看这张图:

细微颜色变化.png

这张图的主要颜色是0b87f0,但其中我以0b87ef0b86f00c86f0三种颜色在上面随手花了几笔,你能找到吗?这种细微变化对于计算机来说是很明显的数值变化(你可以把这张图下载下来,放到Photoshop里,使用魔棒工具在容差为0的设置下随便选一个点,你就能看到Photoshop找到的轮廓),可是对于人眼来说,即便是最精确的显示器,人眼也无法辨别出这种细微的差距。因此这就成为了一种藏匿数据的方法,并且这种方法能够藏匿大量数据。但对应的,这种方法也非常脆弱。如果隐写的数据使用恰当的方式编码,也许可以抵挡剪切、平移、拉伸和旋转;但如果攻击者使用压缩算法处理了图片,那无论怎么编码都没办法保留数据——图像压缩算法的目的就是丢弃肉眼不可见的细节,从而减少数据量,而LSB隐写就是依靠这些细节来藏匿数据的。因此LSB隐写只对位图有效,也就是我们常见的bmp和png文件。

虽然在二进制层面上设计一个抗剪切、平移、拉伸和旋转的编码方式很难,但如果你把格局打开,把被编码的数据当作图像的话,你甚至可以把一个二维码的黑白像素平铺到一张图片中。只要被裁剪的部分还能拼凑出一个完整的二维码,你就可以还原其中的数据。但这样一来,编码的效率就大大减少了。如果你要在一个图片里藏匿二进制文件,那几乎没有什么特别健壮的编码方式。一些冗余校验算法可以纠正部分损失,但是拉伸和旋转大概率是招架不住的。

LSB隐写

说到这里,有灵性的读者应该已经能够猜到LSB如何工作的了:一张图片就是有像素构成的二维数组,而每一个像素由三或四个通道构成。如果是BMP,那就是RGB三个通道;如果是PNG,那就是ARGB四个通道,A表示透明度。虽然通道这个说法听起来很高端,但实际上就是告诉你一个像素有几个值。由于表示透明度的Alpha通道不常用,因此这里我们只考虑RGB三个通道。一般而言一个像素有一个4字节的整数表示:0xAARRGGBBAA表示Alpha通道的值,通常是FF,也就是255;而RR(bit 16到23)代表红色的取值、GG(bit 8到15)代表绿色的取值,而BB(bit 0到7)表示蓝色的取值。我们可以挑一个通道进行隐写,也可以同时在三个通道隐写,增加可以隐写的数据量。

假设我们的图片大小为768*768,那么一共就有589824个像素,每个像素隐写1bit信息,我们就可以隐写72KB的信息。如果我们在三个通道同时隐写,那就可以隐写216KB的数据,这大约相当于一张低质量的JPG图片,或者7万字的中文文本。

代码准备

在开始隐写之前,我想先介绍一些辅助类。首先是用来表示信息的MessageStream。由于在隐写的过程中我们通常是和bit打交道,因此我写了这个类来将被隐写的二进制数据转换成比特流:

interface MessageStream {
    fun nextBit(): Boolean
    val payloadSize: Int
    val extraSize: Int
}

这里的nextBit就是以类似迭代器的模式不断便利每个字节的每一个位(从低到高)。而后面两个size可以先不用管,这是为了后面的变换准备的。由于目前我们还没有什么花哨的变换,我们就直接把数据转换成bit就好了:

abstract class DelegatedMessageStream : MessageStream {
    protected abstract fun readByte(): Int
    protected abstract fun resetStream()

    private var currentByte: Int = 0x00
    private var currentPosition: Int = Byte.SIZE_BITS

    private fun readNextByte() {
        currentByte = readByte()
        if (currentByte == -1) {
            resetStream()
            currentByte = readByte()
        }
    }

    final override fun nextBit(): Boolean {
        if (currentPosition >= Byte.SIZE_BITS) {
            readNextByte()
            currentPosition = 0
        }
        return (currentByte shr (currentPosition++)) and 0x01 != 0
    }
}

这个抽象类实现了一个通用的逻辑:将产生字节数据的任务委托给子类(但是从readByte()返回整形值来看,你们都知道我要委托给谁了吧),而在抽象类中专心处理把字节转换成比特的工作。这里设计让消息本身不断重复,避免攻击者在分析图片时突然发现一个模式的中断(消息结束)。基于这个抽象类,我们可以编写一个PlainMessageStream

class PlainMessageStream(
    message: ByteArray
) : DelegatedMessageStream() {
    override val payloadSize: Int = message.size
    override val extraSize: Int = 0
    private var delegatedStream = ByteArrayInputStream(message)
    override fun readByte(): Int = delegatedStream.read()

    override fun resetStream() = delegatedStream.reset()

    companion object {
        fun decode(
            inputStream: InputStream,
            messageSize: Int
        ): Sequence<ByteArray> = sequence {
            while (true) {
                // read nonce
                val message = inputStream.readNBytes(messageSize)
                if (message.size != messageSize) break
                yield(message)
            }
        }
    }
}

这个类很简单:我们接受一个message数组作为被隐写的数据,然后我们把它包装成一个ByteArrayInputStream,并且将数组的大小作为payload的size,而读起来就直接调用输入流的read(),如果输入流结束了,返回-1,那么父类就会告诉子类从头开始(调用resetStream),而这个工作也直接委托给了输入流的reset()。这里还贴心地附上了解码函数。图片在解码之后将会作为一个输入流,而这里的decode则起到一个解密/逆变换的作用。

为了兼容不同的LSB实现,这里也定义一个接口:

interface LSB {
    fun encode(
        image: BufferedImage, messageStream: MessageStream, randomStream: RandomStream
    ): BufferedImage

    fun decode(image: BufferedImage, randomStream: RandomStream): InputStream
}

这里暂时先不用管RandomStream,它是给一些LSB提供随机性的,目前用不到。对于LSB,定义了两个方法:编码负责将消息隐写到提供的图片中,而解码则根据图片读取出其中的消息,将其表示为一个输入流。

在此基础上还可以写一个抽象类来负责共同的逻辑:

abstract class LinearLSB : LSB {
    protected abstract fun encodeRandom(
        x: Int, y: Int, originalARGB: Int,
        randomStream: RandomStream, messageStream: MessageStream
    ): Int

    protected abstract fun decodeRandom(
        x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream
    ): List<Boolean>

    final override fun encode(
        image: BufferedImage, messageStream: MessageStream, randomStream: RandomStream
    ): BufferedImage {
        val result = BufferedImage(image.width, image.height, image.type)

        for (y in 0 until result.height) {
            for (x in 0 until result.width) {
                val newColor = encodeRandom(x, y, image.getRGB(x, y), randomStream, messageStream)
                result.setRGB(x, y, newColor)
            }
        }
        return result
    }

    final override fun decode(image: BufferedImage, randomStream: RandomStream): InputStream =
        object : InputStream() {
            private val seq = sequence {
                var value = 0x00
                var pointer = 0
                for (y in 0 until image.height) {
                    for (x in 0 until image.width) {
                        decodeRandom(x, y, image.getRGB(x, y), randomStream).forEach {
                            if (it) value = value or (0x01 shl pointer)
                            pointer++
                            if (pointer >= Byte.SIZE_BITS) {
                                yield(value.toByte())
                                pointer = 0
                                value = 0
                            }
                        }
                    }
                }
            }

            private var currentIter = seq.iterator()

            override fun read(): Int =
                if (currentIter.hasNext()) currentIter.next().toUByte().toInt() else -1

            override fun available(): Int =
                if (currentIter.hasNext()) 1 else 0

            override fun reset() {
                currentIter = seq.iterator()
            }
        }

    protected fun setBit(data: Int, mask: Int, messageBit: Boolean): Int =
        if (messageBit) {
            data or mask // set bit to 1
        } else {
            data and (mask.inv()) // set bit to 0
        }
}

由于之后实现的都是顺序遍历的LSB,所以这里搞了一个LinearLSB,它将顺序遍历每一个像素,然后让子类将信息编码到像素中。抽象的encodeRandom向子类提供当前像素的坐标和值,还有随机流以及信息流,子类返回经过编码后的像素值;解码时通过向子类提供类似的信息,子类返回一个比特集合。这是考虑到子类可以在一个像素中编码多个比特的信息,也可以不编码信息。

三通道固定编码LSB

接下来终于可以实现我们的第一个LSB隐写了。这个LSB隐写的实现比较简单,就是之前说的每个像素编码分别在RGB三个通道同时编码,即一个像素编码3个比特:

class RGBFixedBit0LSB : LinearLSB() {
    override fun encodeRandom(
        x: Int, y: Int, originalARGB: Int,
        randomStream: RandomStream, messageStream: MessageStream
    ): Int {
        var data = originalARGB
        // R -> G -> B
        for (i in 2 downTo 0) {
            data = setBit(data, 0x01 shl i * 8, messageStream.nextBit())
        }
        return data
    }

    override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> {
        val data = mutableListOf<Boolean>()
        // R -> G -> B
        for (i in 2 downTo 0) {
            data.add(pixelARGB and (0x01 shl i * 8) != 0)
        }
        return data
    }
}

在编码时,我们按照红、绿、蓝三个通道的顺序分别编码三个比特,解码时按照同样顺序读出这三个比特。编码效果如下:

FixedLSB对照.png

左边是原图,右边是编码后的图。这里编码的消息大小正好是768比特,而图片也正好是768比特,因此右图的右上角能隐约看到一些竖直方向的条带。

关于生成图片的完整代码,以及后文提到的隐写分析的代码,可以参考GitHub Repo hurui200320/steganography-demo

隐写分析

怎么知道隐写的效果好不好呢?或者说作为攻击者,我们如何分析一张图片有没有包含隐写内容呢?最简单的办法就是放到Photoshop里看直方图:

FixedLSB直方图.png

左边是原图,右边是经过隐写的,你会发现隐写之后的直方图出现了一堆锯齿。为什么会这样呢?我们不妨来看看它的位平面。由于隐写只发生在每个通道的bit0上,所以我们只看bit plane 0:

FixedLSB位平面.png

从上到下分别是RGB三个通道,左边是原图,右边是隐写后的图片。因为隐写过程完全覆盖了原有的值,所以覆写之后导致原本应该平滑的直方图,因为像素随着有规律的数据被迫变大或变小1位,因此变得具有明显的锯齿。原本的图片色彩越平滑,这个问题越严重。如果选用噪点多一些的图片,则能够在一定程度上缓解这个问题。

最后我们来看看随机颜色映射(Random Color Map),这种分析方法将每一种颜色随机且唯一地映射到另一种颜色。这种变换能够将原本相邻的、肉眼无法察觉的细微变化,通过随即映射来放大:

FixedLSB随机颜色映射.png

左边是原图,右边是隐写后的图。可以看到,虽然左侧的颜色已经变得乱七八糟,但是仍然能分辨出一些平滑的色块。而右边则很明显地能看出来我们隐写的数据。

以上三种方法应该是目前我知道的最常见的方法了,除此之外还有基于统计学的方法,这些我没有找到太多资料,但整体思路大约是通过统计学特征(方差、熵等)来辨别隐写。况且基于统计学的方法大多是计算,没有什么可视化的过程,对于写文章来说有些无聊(但确实是最方便的,公式一贴,代码一贴,运行结果一贴就完事儿了),所以之后我也只使用前面这三种方法来分析隐写。请注意,隐写分析并不只是这几种方法,随着各学科不断发展,近年来也有使用人工智能和神经网络来分析隐写的方法。还是开头那句话:你需要自己评估并决定承担一种方法的风险

三通道随机编码LSB(Bit 0)

如果说要改进前一种隐写方式,我首先能想到的就是引入随机性,并牺牲一些隐写容量。我们在一个像素中只编码一个比特,但是我们使用随机过程来决定在哪个通道中编码。这样一来也算是对被编码数据的一种保护。

随机决定编码通道要求接收者使用同样的随机过程才能正确解读隐写内容,否则就会把图片的数据当成隐写内容读出。但这并不等同于加密,如果隐写的内容具备已知且明显的特征,比如说ASCII字符,那么通过剪枝就可以以8个像素位单位排除掉非法字符,然后以英文单词为基础排除掉无意义的组合,还是可以破解出隐写的内容。

这里引入了RandomStream来提供随机过程:

interface RandomStream {
    fun read(): Int
}

这里返回的数值范围在0到255之间。由于大部分随机源都是以字节数组的方式交互的,因此这里编写了一个抽象类来从字节数组中提供随机数:

abstract class DelegatedRandomStream(
    protected val buffer: ByteArray
) : RandomStream {
    private var bufferPosition: Int = buffer.size

    protected abstract fun reloadBuffer()
    final override fun read(): Int {
        if (bufferPosition >= buffer.size) {
            reloadBuffer()
            bufferPosition = 0
        }
        return buffer[bufferPosition++].toUByte().toInt()
    }
}

子类需要提供一个buffer,并根据父类的指示重载这个buffer。框架有了,问题是,我们要选择什么样的随机数算法呢?

首先我们要求这个随机数必须是安全的。类似于生成随机密钥,你当然不想别人能够根据密钥的一部分而预测到后面的内容。针对这种需求,人们提出了CSPRNG这个概念,全称Cryptographically secure pseudorandom number generator,即密码学安全伪随机数生成器。

其次我们还需要这个随机数生成器是可重复的。大多数情况下,密码学安全的伪随机数生成器只能保证它提供的随机性是不可预测的,最安全的随机数生成器包括基于大气噪声的random.org,还有基于硬件熵池的随机数生成器。但他们的问题在于不可重复,这样一来在解码时我们不知道随机序列,那就没办法解码了。我们当然可以把随机选择的通道记录下来,作为解码隐写信息的条件。但是这种方法产生的额外信息长度取决于图片大小,对于一张768*768的图片,这种信息很容易就能达到144KB,悬一悬都快赶上一张图片了。如果随机过程是可重复的,那我们只需要记录一个简短的seed就可以重复产生这个随机序列了。

这里选择了两个随机数生成算法。一种是JVM自带的SHA1PRNG,这种算法通过SHA1散列函数来生成随机数。当本地随机数不可用时,JVM将退回到这种算法来为SecureRandom提供随机性。另一种是来自BouncyCastle的VMPC(Variably Modified Permutation Composition,我也不知道是什么意思,但是ChatGPT说这东西很安全),它的全称类名是org.bouncycastle.crypto.prng.VMPCRandomGenerator。关于这两个算法对应的RandomStream就不多赘述了,无非就是调用对应的生成器产生字节数据,然后交给DelegatedRandomStream处理。好奇的话这部分代码可以在info.skyblond.steganography.prng包下找到。

接下来看看这种随机编码的LSB的实现:

class RGBRandomBit0LSB : LinearLSB() {
    override fun encodeRandom(
        x: Int, y: Int, originalARGB: Int,
        randomStream: RandomStream, messageStream: MessageStream
    ): Int {
        // 0 -> Blue (7-0)
        // 1 -> Green (15-8)
        // 2 -> Red (23-16)
        val channel = randomStream.read() % 4
        // skip if channel is 3
        if (channel == 3) return originalARGB
        return setBit(originalARGB, 0x01 shl channel * 8, messageStream.nextBit())
    }

    override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> {
        val channel = randomStream.read() % 4
        // skip if channel is 3
        if (channel == 3) return emptyList()
        return listOf(pixelARGB and (0x01 shl channel * 8) != 0)
    }
}

这里我们读了一个随机数,然后取4的余数,也就是低两位。0表示使用蓝色通道(LSB正好是bit 0);1表示使用绿色通道(LSB是bit 8);2表示使用红色通道(LSB是bit16);如果遇到3,则这个像素不编码。

解码时使用同样的seed初始化对应的RandomStream,可以保证在解码时将产生完全一样的随机序列。此时根据随机序列的结果读取对应的比特即可。我们来看看效果:

Random0LSB对照.png

左边是原图,右边是隐写后的图。虽然垂直方向的条带仍然隐约可见,但是不仔细看的话还是很难发现的。这是因为我们的数据恰巧是768比特,因此特征比较明显。接下来我们看看隐写分析的结果。首先是直方图。

Random0LSB直方图.png

这次可以看到那些明显的锯齿消失了。我们看看bit plane 0:

Random0LSB位平面.png

可以看到,这次隐写保留了大部分原有的信息,我们能够在位平面中辨识出原图的一些细节。但是由于隐写信息的存在,可以看到一些本该平滑的色块经过隐写后引入了噪点。这些噪点正是我们随机隐写的数据。最后我们看看随机颜色映射:

Random0LSB随机颜色映射.png

这次没有那么明显的特征的,但是一张色彩平滑的图片包含那么多噪点,还是很可疑啊。

三通道随机编码(Bit 0和1)以及数据加密

虽然前面的方法隐蔽了特征,但还是留下了一些噪点。为了进一步稀释这些噪音,我们不妨把bit 1也加入到编码的行列中:

class RGBRandomBit01LSB : LinearLSB() {
    override fun encodeRandom(
        x: Int, y: Int, originalARGB: Int,
        randomStream: RandomStream, messageStream: MessageStream
    ): Int {
        // 0 -> Blue (7-0)
        // 1 -> Green (15-8)
        // 2 -> Red (23-16)
        val channel = randomStream.read() % 4
        // skip if channel is 3
        if (channel == 3) return originalARGB
        val bit = randomStream.read() % 2
        return setBit(originalARGB, 0x01 shl (channel * 8 + bit), messageStream.nextBit())
    }

    override fun decodeRandom(x: Int, y: Int, pixelARGB: Int, randomStream: RandomStream): List<Boolean> {
        val channel = randomStream.read() % 4
        // skip if channel is 3
        if (channel == 3) return emptyList()
        val bit = randomStream.read() % 2
        return listOf(pixelARGB and (0x01 shl (channel * 8 + bit)) != 0)
    }
}

这样一来每个通道的值将在±2之间变化,这种变化对于肉眼来说一样是难以分辨的。这里我们不光随机挑选用于隐写的通道,我们还随机挑选用于隐写的位平面。这次除了直接写入数据,我们还可以对数据加密。一个恰当的加密算法应该使密文在统计学上看起来像白噪音:

class EncryptedMessageStream(
    private val rawMessage: ByteArray,
    private val key: ByteArray,
) : DelegatedMessageStream() {

    // Each loop has different count, thus the result is different
    private var loopCount = 0
    private var delegatedStream: InputStream

    override val extraSize: Int
    override val payloadSize: Int

    init {
        require(key.size == 32)
        val (nonce, payload) = encryptMessage(key, loopCount.toByteArray() + rawMessage)
        delegatedStream = ByteArrayInputStream(nonce + payload)
        extraSize = nonce.size
        payloadSize = payload.size
    }

    override fun readByte(): Int = delegatedStream.read()

    override fun resetStream() {
        loopCount++
        val (nonce, payload) = encryptMessage(key, loopCount.toByteArray() + rawMessage)
        check(nonce.size == extraSize)
        check(payload.size == payloadSize)
        delegatedStream = ByteArrayInputStream(nonce + payload)
    }

    private fun encryptMessage(key: ByteArray, message: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = GCMSIVBlockCipher(AESEngine())
        val nonce = ByteArray(12)
        SecureRandom().nextBytes(nonce)
        cipher.init(true, AEADParameters(KeyParameter(key), 128, nonce))
        val result = ByteArray(cipher.getOutputSize(message.size))
        val resultSize = cipher.processBytes(message, 0, message.size, result, 0)
        cipher.doFinal(result, resultSize)
        return nonce to result
    }

    companion object {
        fun decode(
            inputStream: InputStream,
            key: ByteArray,
            nonceSize: Int, payloadSize: Int
        ): Sequence<ByteArray> = sequence {
            while (true) {
                // read nonce
                val nonce = inputStream.readNBytes(nonceSize)
                if (nonce.size != nonceSize) break
                val payload = inputStream.readNBytes(payloadSize)
                if (payload.size != payloadSize) break
                try {
                    // skip the 4 bytes loop counter
                    yield(decryptMessage(key, payload, nonce).let { it.copyOfRange(4, it.size) })
                } catch (t: Throwable) {
                    // bad block, skip
                }
            }
        }

        private fun decryptMessage(key: ByteArray, encryptedMessage: ByteArray, nonce: ByteArray): ByteArray {
            val cipher = GCMSIVBlockCipher(AESEngine())
            cipher.init(false, AEADParameters(KeyParameter(key), 128, nonce))
            val result = ByteArray(cipher.getOutputSize(encryptedMessage.size))
            val resultSize = cipher.processBytes(encryptedMessage, 0, encryptedMessage.size, result, 0)
            cipher.doFinal(result, resultSize)
            return result
        }
    }
}

这里选用了AES-256-GCM-SIV。AES-GCM算法能够均匀的加密数据,并且GCM自带验证,确保我们读到了正确的数据,同时SIV缓解了重用nonce的风险。同时为了避免同一段数据被不停的复读进而产生可辩别的模式,这里还引入了一个计数器。每次过完一次信息之后,会将计数器的值加一,并将计数器的值作为消息的一部分加密,这样每一次写入的数据都不一样。效果如下:

Random01LSB对照.png

这次看起来好像没什么破绽了,毕竟从原理上就是随机添加了一些噪点。接下来我们看看直方图:

Random01LSB直方图.png

这次的直方图已经很接近原图了,但是由于隐写替换的缘故,我们不可能做到和原图一模一样。接下来看看位平面:

Random01LSB位平面.png

因为这次隐写到了两个位平面,所以左侧是位平面0,右侧是位平面1,而每个位平面的左侧是原图,右侧是隐写之后的图,从上到下依次是RGB三个通道。可以看到每个位平面的噪音减少了很多。最后来看看随机颜色映射:

Random01LSB随机颜色映射.png

嗯,看起来噪点还是很明显,无论怎么看还是很可疑。

利用噪点

虽然噪点在大多数时候很恼人,无论是摄影还是什么领域,我们都不希望出现噪点。但在隐写这个特殊领域中,噪点正是我们的好朋友。我前面的演示使用的是一张由AI生成的,整体色彩比较平滑的图片。所谓平滑,就是图片中存在大量颜色相同的色块,这种色块中只要出现一丁点噪声,就会显得不对劲。

藏匿数据和撒谎其实没什么区别:如果一个谎言全是假的,那你编织得再完整,它依旧是假的;倘若你在其中掺杂一些真实的东西,那你的谎言就看起来非常真实了。对于藏匿数据来说,什么是真实的呢?答案是大自然。

如果你对着细节丰富的东西拍一张照片,即便经过JPEG压缩,他还是细节丰富的:

屏幕截图 2023-06-14 222022.png

这张图即便经过JPG算法压缩,可是放大之后还是有很多噪点。如果没有原图的话,攻击者根本无法分辨这到底是照片本身如此,还是人为添加了噪点(隐写信息)。不过这里很重要的一点是没有原图。如果攻击者能够轻易获得原图,那么只要跟你的图片进行对比,立刻就能看出端倪。至于如何删除原图、藏匿隐写程序,这算是安全实践方面的内容,就不再本文的讨论之列了。如果你觉得自然风光的噪点不够,那么你有没有试过抬起头来仰望星空呢?

结语

本来我准备了8张图片来演示这个算法,但是除了AI生成的那个,剩下的图片基本上都以MB计算,并且大多数图片处理完之后都占用30MB上下,最大的甚至有50MB的。想来想去,为了我的服务器和读者的流量着想,我决定不把图放上来了。但是我有把完整的代码和用图放在GitHub仓库里,欢迎查阅。

我想把这个系列写下去,并且下一篇文章打算写一写DFT(Discrete Fourier transform,离散傅里叶变换)隐写。实际上代码已经写了一部分了,但是测试了各种方法,我还没找到能够将数据稳定隐写在频域的办法。之后还想写写DCT(离散余弦变换,也就是JPG压缩算法使用的变换),最后ChatGPT还跟我说有一种DWT,离散小波变换,这说的我一脸茫然,虽然我是学计算机的不假,但是这听起来确实像是信号那一块的事情。我这大学四年就没学过信号相关的东西。看起来需要补充一下知识了。鉴于我后面想写的东西全都是我不熟悉的东西,所以如果这个系列变成坑了也请大家不要大惊小怪,可能是我真的没学会。

-全文完-


知识共享许可协议
【代码札记】初探图片隐写术(Steganography)之LSB隐写天空 Blond 采用 知识共享 署名 - 非商业性使用 - 相同方式共享 4.0 国际 许可协议进行许可。
本许可协议授权之外的使用权限可以从 https://skyblond.info/about.html 处获得。

Archives QR Code
QR Code for this page
Tipping QR Code