图中藏(cáng)语

今天分享一个好玩工具,这个工具可以往图片中藏入信息,当然也可以从藏有信息的图片中将信息找出来。专业名词的话,这叫图片隐写技术,但无所谓了,我们知道是咋回事儿就好。本文将介绍如何使用 python3 实现这个工具。

20181020154000344643954.png

原理

在实现工具之前,先了解一下原理。

隐写术

隐写术是一门关于信息隐藏的技巧与科学,所谓信息隐藏指的是不让除预期的接收者之外的任何人知晓信息的传递事件或者信息的内容。

—— 隐写术 @维基百科

可以类比一下无线电,无线电传递信息的原理可以简单地理解为:发射端在载波上注入高频信号,而接收端收到信号后解析载波上的高频信号,从而实现信息的无线传输,其中载波的波长远大于载入信号的波长。同理图片隐写术中图片就是信息载体文件,载体文件(cover file)相对隐秘文件的大小(指数据含量,以比特计)越大,隐藏后者就越加容易。

数据存储

这里选择位图图片作为载体,一张8位RGB的 JPG 位图中的一个像素包含 R、G、B 三个通道的数值,每个通道数值为8位数据,也就是 0b000000000b11111111 的数值,想象一下其中一个通道,就以 R 通道来讲,0b111011110b11101110 只是最低位不一样,实际图片的表现上,这种差别肉眼几乎是无法察觉的,所以可以利用每个通道的最低位来存储 1bit 的数据是可行的^[更正式一点地说,使隐写的信息难以探测的,也就是保证“有效载荷”(需要被隐蔽的信号)对“载体”(即原始的信号)的调制对载体的影响看起来(理想状况下甚至在统计上)可以忽略。这就是说,这种改变应该无法与载体中的噪声加以区别。从信息论的观点来看,这就是说信道的容量必须大于传输“表面上”的信号的需求。这就叫做信道的冗余。「来自隐写术」]。

20181020154001752117286.png

如此的话每个像素三个通道共可以存储 3 bits,那如果像 png 图片一样再多一个 Alpha 通道(透明通道),就可以存储 4 bits,两个像素就可以存储一个字节,方便处理。另外注意的是,我们这里存储和读取字节数据均采用 Little-Endian方式^[(小字节序、低字节序),即低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。 与之对应的是:BIG-ENDIAN(大字节序、高字节序)],后面写程序的时候要清楚!

实现

messageimage.py 文件中为图片处理实现一个类 MessageImage ,然后编写一个 python 脚本命令行工具,保存为文件 codeimg

MessageImage

本文实现的类中,涉及了 pillow 库的使用,字符串的编码转换等知识。

问题分析

小节 原理-数据存储 中介绍了隐写信息在图片中的存储方式,数据写入和读取要参考这个原理,但是这里还需要考虑两个问题,针对这两个问题,分别提出解决方法,剩下的就是代码实现的问题了。

图片没有alpha通道问题

对于不含透明通道的位图图片(比如jpg)来说,少一个 Alpha 通道,那么 原理-数据存储 提到的四通道存储方案显然缺少通道,其实解决很简单,只需要使用 pillow 库将 jpg 图片数据转换为带 alpha 通道的数据即可进行,可使用如下的方法转换:

from PIL import Image
img = Image.open('demo.jpg').convert('RGBA')

直接转换为 RGBA 模式即可。

怎么判断图片有没有隐写信息问题

为了判断图片有没有隐写信息,这里设计一个字节数据包存储协议,协议很简单,如下表:

head length data
0x5555AAAA 数据长度 数据
占 4 字节 占 4 字节 占 length 字节

最开始的4个字节为标记,再之后的四字节表示图片中信息的字节数目,之后则是 length 个字节的主要数据。所以判断有没有隐写信息的流程应该是:

  1. 从图片中前16个像素数据中,解析出8个字节;
  2. 判断前4个字节与预设的 head 是不是一致,如果一致则说明有隐写信息;

当然,head 是可以任意定义的,通过此方法,判断有没有隐写信息出错的概率为:

$$ p = \frac{1}{2^{32}} = 0.000000000232831 $$

准备工作

  1. 这里使用的第三方库是图片处理库 pillow,安装很简单:

    $ pip3 install pillow
    
  2. 新建文件 messageimage.py

    $ touch messageimage
    
  3. 导入需要的库,向文件 messageimage.py 中添加以下内容

    import os
    from PIL import Image
    
  4. 声明类 MessageImage,文件 messageimage.py 中添加:

    class MessageImage:
        """ MessageImage 类
        初始化加载图片或者手动打开图片,将隐写信息写入图片,也可以解析图片中的隐写信息
        Attributes:
            image: 打开图片的 Image 类型对象
            pkghead: 写入图片隐写信息的头标记,可以初始化时赋值
        """
    

    剩下的就是添加类的方法了。

公共方法实现

为类实现下面几个公共方法:

方法 描述 参数 返回值
__init__(self, imgpath, pkghead=None) 初始化类,生成 MessageImage 实例 imgpath:图片路径;pkghead:自定义协议头
encode(self, info, save=True, show=False) 将指定的隐写信息写入图片 info:隐写信息(字符串);save:控制是否将写入信息的图片进行保存(默认 True);show:控制是否在写入信息后显示结果图片(默认 False) 写入成功就返回 True,失败返回 False
decode(self) 解析图片中的隐写信息 如果解析成功,返回隐写信息,其它情况返回 None
open(self, imgpath) 打开指定图片,转换数据为 RGBA 模式数据赋给属性 image imgpath:图片路径
freespace(self) 计算图片中可用空间,单位 byte 返回可用空间大小,单位字节
sethead(self, pkghead) 设置协议头 pkghead:自定义协议头

初始化

类的初始化,主要是根据指定图片和指定存储协议头初始化基本属性:

def __init__(self, imgpath, pkghead=None):
    if imgpath:
        self.open(imgpath)
    else:
        self.image = None
    if pkghead:
        self.pkghead = pkghead
    else:
        self.pkghead = 0x5555AAAA

上述操作中用到了 open 方法。

encode方法

encode 方法的思路依赖于 原理-数据存储问题分析,实现思路为:

  1. 使用私有方法 __pack 打包指定的隐写信息,得到一个完整的协议报数据;
  2. __putdata 将协议包数据写入图片数据,得到新的图片数据;

实现如下:

def encode(self, info, save=True, show=False):
    if self.image is None:
        print('图片为空,请调用实例的 open 方法打开一张图片!')
        return False
    pkgbytes = self.__pack(info)
    self.__putdata(pkgbytes)
    if save:
        pathlist = os.path.splitext(self.imgpath)
        newpath = pathlist[0] + '_code' + '.png'
        print(newpath, end=' ')
        self.image.save(newpath)
    if show:
        self.image.show()
    return True
__pack

这个方法主要是按照 怎么判断图片有没有隐写信息问题 小节中提到的协议进行打包,具体实现如下:

def __pack(self, info):
    """
    打包数据,转换为待写入图片的字节数据组,并返回
    """
    tagbytes = self.pkghead.to_bytes(4, 'little')
    databytes = bytearray(info, 'utf8')
    lengthbytes = len(databytes).to_bytes(4, 'little')
    pkgbytes = tagbytes + lengthbytes + databytes
    return pkgbytes

这里有两点关键:

__putdata
def __putdata(self, pkgbytes):
    pixels = list(self.image.getdata())
    freespace = self.freespace()
    if len(pkgbytes) > freespace:  # 超出全部数据空间, 抛出异常
        raise Exception("错误: 不能载入超过 " + freespace + " 字节的数据到图片中。")
    for index, byte in enumerate(pkgbytes):
        _index = 2 * index
        pixels[_index] = tuple([(v & 0xFE) | (((byte & 0x0F) >> i) & 0x01)  for i, v in enumerate(pixels[_index])])
        _index += 1
        pixels[_index] = tuple([(v & 0xFE) | (((byte >> 4) >> i) & 0x01)  for i, v in enumerate(pixels[_index])])
    newimg = Image.new(self.image.mode, self.image.size)
    newimg.putdata(pixels)
    self.image = newimg

简单说一下上面实现的过程:

  1. 得到包含像素数据的列表,这个列表的元素是 tupletuple 包含的四个数据就是对应像素的 R、G、B、A 四个通道的数值;

  2. 为了处理待写入数据超过图片可存空间的情况,这里调用 freespace 获取可用空间与待写数据大小比较,如果超出就发起异常!

  3. 如果空间足够,就将隐写数据写入到像素数据中,这一部分实现在 for 循环中,枚举隐写数据中的字节,每个字节需要写入两个像素数据,低位在前原则,这里解释一下下面的语句:

    tuple([(v & 0xFE) | (((byte & 0x0F) >> i) & 0x01)  for i, v in enumerate(pixels[_index])])
    tuple([(v & 0xFE) | (((byte >> 4) >> i) & 0x01)  for i, v in enumerate(pixels[_index])])
    
    • iv 分别是像素数据中通道数据的索引和值;
    • v & 0xFE :将通道数据的最低位置0;
    • ((byte & 0x0F) >> i) & 0x01 :获取待写入字节低4位中的每一位数据,最终得到 0 或 1
    • ((byte >> 4) >> i) & 0x01 :获取待写入字节高4位中的每一位数据,最终得到 0 或 1
  4. 根据写入隐写信息图片数据创建新的图片对象赋值给属性 image

decode方法

这个方法用来解析图片中的隐写信息,实现如下:

def decode(self):
    if self.image is None:
        print('图片为空,请调用实例的 open 方法打开一张图片!')
        return None        
    return self.__unpack()

直接关键是调用了解包函数 __unpack,返回的是它的结果。

__unpack

用于从图片中检查是否包含隐写数据并解析隐写数据,实现:

def __unpack(self):
    pkgbytes = self.__getdata(0, 8)
    pkghead = int.from_bytes(pkgbytes[0:4], 'little')
    if pkghead == self.pkghead:
        length = int.from_bytes(pkgbytes[4:8], 'little')
        pkgbytes = self.__getdata(8, 8 + length)
        info = str(pkgbytes, encoding='utf8')
        return info
    else:
        return None
  1. 首先通过 __getdata 方法从图片数据中获取存储的前8个字节数据;
  2. 使用 intfrom_bytes 方法^[字节到大整数的打包与解包]得到前4个字节代表的协议头;
  3. 比较读取的协议头是否与预设的协议头一致,若一致则进行下面的步骤,若不一致则返回结果 None
  4. 同样使用 intfrom_bytes 方法从之后的4个字节得到隐写数据的长度;
  5. 根据长度调用 __getdata 方法从图片中获取隐写数据;
  6. 将得到的字节数组转换为编码为 utf8 的字符串;
__getdata
def __getdata(self, start, stop):
    pixels = list(self.image.getdata())
    pkgbytes = bytearray()
    for i in range(start, stop):
        (r, g, b, a) = tuple([v & 0x01 for v in pixels[2 * i]])
        v = r | (g << 1) | (b << 2) | (a << 3)
        (r, g, b, a) = tuple([v & 0x01 for v in pixels[2 * i + 1]])
        v |= (r << 4) | (g << 5) | (b << 6) | (a << 7)
        pkgbytes.append(v.to_bytes(4, 'little')[0])
    return pkgbytes  

这个方法可以从图片数据中获取像素存储的位数据并组成字节数据。其中:

  • start:要读取字节的开始位置
  • stop:要读取字节的结束位置

所以实现中,for 的每一次循环读取两个像素点数据,组得一个字节数据,上述代码第 5 和第 7 行代码,得到的是每个通道的最低位数值。第 9 行代码将得到的数值转换为单字节数据。

open方法

这个方法用来打开新的图片获取 RGBA 模式的图片数据覆盖原来的属性 image,并记录图片路径到属性 imgpath,实现如下:

    def open(self, imgpath):
        try:
            self.image = Image.open(imgpath).convert('RGBA')
            self.imgpath = imgpath
        except:
            self.image = None

freespace方法

这个方法用来计算图片可存隐写数据的空间大小,单位是字节,实现如下:

def freespace(self):
    """
    查看图片存储隐藏数据的可用空间,单位 byte
    """
    if self.image is None:
        print('图片为空,请调用实例的 open 方法打开一张图片!')
        return 0
    size = self.image.size
    return int(size[0] * size[1] / 2)

像素个数的一半就是可存数据的空间大小,因为两个像素存一个字节数据。

sethead方法

有时可能需要自定义协议头,所以设计这个方法:

def sethead(self, pkghead):
    if pkghead:
        self.pkghead = pkghead

类测试

最终实现了 MessageImage 类。需要测试一下,添加代码:

if __name__ == '__main__':
    mi = MessageImage('demo.png')
    mi.encode('你好,世界!hello, world!')
    mi.open('demo_code.png')
    print(mi.decode())

这里以图片 demo.png 为例,如果最终打印了解析结果,就说明 MessageImage 类搞定了,类似于:

$ python messageimage.py
demo_code.png 你好,世界!hello, world!

完整代码参考 tools-with-script/codeimg/messageimage.py

命令行工具

有了 MessageImage 类,命令行工具就好实现了。

准备工作

  1. 新建文件:touch codeimg,并为文件添加运行权限 chmod +x codeimg

  2. 文件添加以下内容指定运行解释器:

    #!/usr/bin/env python3
    
  3. 这里使用 MessageImage 类和 argparse,需要导入库,文件添加:

    import argparse
    from messageimage import MessageImage
    

获取参数方法

封装获取命令行参数的方法为 getargs:

def getargs():
    parser = argparse.ArgumentParser(description="为图片写入隐藏信息,或者从包含隐藏信息的图片中获取信息。")
    parser.add_argument('command', choices=['encode', 'decode', 'check'], help='指定子命令。encode -> 往图片中写入隐藏信息;decode -> 读取图片中的隐藏信息;check -> 查看图片可存空间(单位 byte)')
    parser.add_argument('-i', '--info', help='指定要写入的隐藏信息,子命令为 encode 时有效')
    parser.add_argument('imgpath', help='指定要处理的图片')
    return parser.parse_args()

设置三个子命令:

  • encode : 往图片中写入隐藏信息;
  • decode : 读取图片中的隐藏信息;
  • check : 查看图片可存空间(单位 byte)

imgpath 图片路径是必须参数。

参数处理

最终脚本的处理如下:

if __name__ == '__main__':
    args = getargs()
    mi = MessageImage(args.imgpath)
    if args.command == 'encode':
        if args.info is None:
            args.info = input('请输入要隐藏的信息:')
        if mi.encode(args.info):
                print('已写入隐藏信息!')
    elif args.command == 'decode':
        print(f'解析到隐藏信息:{mi.decode()}')
    elif args.command == 'check':
        print(f'图片可用空间 {mi.freespace()} bytes')
    else:
        print(f'不支持 {args.command} 命令')

大功告成!命令行工具的代码参考:tools-with-script/codeimg/codeimg

测试

同样以demo.png 为例,测试过程如下:

$ ./codeimg encode demo.png -i 'https://www.smslit.top'
demo_code.png 已写入隐藏信息
$ ./codeimg decode demo_code.png
解析到隐藏信息https://www.smslit.top
$ ./codeimg check demo.png
图片可用空间 381024 bytes
$ ./codeimg -h
usage: codeimg [-h] [-i INFO] {encode,decode,check} imgpath

为图片写入隐藏信息或者从包含隐藏信息的图片中获取信息

positional arguments:
  {encode,decode,check}
                        指定子命令encode -> 往图片中写入隐藏信息decode -> 读取图片中的隐藏信息check
                        -> 查看图片可存空间(单位 byte)
  imgpath               指定要处理的图片

optional arguments:
  -h, --help            show this help message and exit
  -i INFO, --info INFO  指定要写入的隐藏信息子命令为 encode 时有效

总结

本文实现了图片隐写术的基本功能,过程中熟悉了pillow 库、 argparse 库的基本使用以及类的编写,同时还了解了字节与字符串、字节与整型数据的相互转换。

如果您觉得本文写的还不错,请持续关注 https://www.smslit.top,后面还会有好玩的东西分享!