LSB隐写术简单实现

我的本行

Posted by Miso on February 13, 2026

0. 前言 关于LSB(最低有效位)隐写术


LSB隐写术是一种在图像像素中隐藏信息的技术,主要通过修改图片像素的最低有效位来实现。由于最低有效位的修改对像素的颜色影响极小,LSB隐写后的图片和原图对于肉眼来说几乎是无法分辨的。正因如此,LSB在信息隐匿传输、数字水印等领域均有广泛应用。

本文将使用Python实现最简单的LSB隐写,封装encode(message: str)和decode()两个简洁接口。仅实现单通道连续嵌入、.png图片类型、ASCII字符消息。

我们出发!

1. 前置知识和辅助函数


Python bytes对象

bytes是Python表达字节序列的一种容器对象。我们可以简单地将它理解为list[int],其中int的取值范围是0~255(八位二进制数可表示的范围),每一项整数都代表着一个字节。

本项目中,我们需要把整数int和字符串str转换为字节流,其方法分别是:

int_bytes: bytes = a_int.to_bytes(2, "big")

将整数转为二进制并用2字节承载 大端序

str_bytes: bytes = a_str.encode()

将字符串中的每个字符进行ASCII(实为UTF-8)编码并作为一个字节

字节流对象还可以通过真正的list[int]生成,只要int全部处于0到255范围之间。将这个列表传入bytes的构造方法即可。

把字节流变为比特流

在进行图片隐写时,我们需要直接取得每个字节的八个0和1(比特)来注入最低位。因此,需要一个函数依据字节序列对象生成比特流。

1
2
3
4
5
6
7
8
9
def generate_bit_flow(data: bytes) -> Generator[Literal[0,1], Any, None]:
    """
    从字节流对象生成比特流
    :param data: 字节流对象
    :return: 比特流的生成器
    """
    for _byte in data:
        for _bit in format(_byte, "08b"):
            yield int(_bit)

_byte: int遍历每一个字节,并通过format(_byte, "08b")生成了左边补0的八位二进制字符串,再经由_bit遍历字符串里的0和1,进行产出(而不是返回)。

我使用了生成器语法来减少内存开销。这个函数把list[int # 0 ~ 255]转换为八倍长度的list[int # 0 or 1],从字节流中取得比特。

从二进制比特流中取回整数与字符串

假设我们已经把二进制数据整理成了二进制字符串binary_str = "000000101"的形式,就可以通过int的构造方法直接把它转化为十进制整数,比如:

length = int(binary_str, 2),参数2代表“以二进制解读”

从二进制字符串转换回字符串的代码略显复杂,我将在后文的代码实现中展示。

数电知识:修改与读取整数的最低位

我们使用位运算来快速修改整数的最低位,也可以通过奇偶判断后加减1的方式进行修改。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
def set_lowest_to(target: Literal[0,1], num: int) -> int:
    """
    修改一个整数的二进制最低位
    :param target: 最低位需求
    :param num: 原整数
    :return: 修改后的整数
    """
    match target:
        case 0:
            return num & -2  # -2 == 11111110
        case 1:
            return num | 1  # 1 == 00000001
        case _:
            raise ValueError

def get_lowest(num: int) -> int:
    """
    取得一个整数的最低位
    :param num: 需检测的整数
    :return: 二进制最低位
    """
    return num % 2

2. 使用PIL库的Image类对图像进行编辑


PIL提供了极其强大简洁的接口来访问、编辑一副图像,具体举例如下:

1
2
3
4
5
6
7
8
9
10
raw_file: Path = list(INPUT_PATH.glob("*.png"))[0]
with Image.open(raw_file) as img:
    pixels = img.load()  # 返回像素组对象
    width, height = img.size  # 读取图片尺寸
        
    r, g, b = pixels[(x, y)]  # 读取像素信息
        
    pixels[(x, y)] = (0x66, 0xcc, 0xff)  # 设置像素 洛天依蓝

    img.save(OUTPUT_PATH / "result.png")  # 存储图片

我们可以通过这些接口对像素进行读写。

3. 写入操作的代码实现


为了确定隐写内容是从开头一直读到哪里,我们需要在开头加入一个描述长度的整数。按CTF惯例,这个长度前缀以2字节为佳。这个长度最高可表示65,535个ASCII字符。

1
2
3
4
5
    @staticmethod
    def encode(message: str):

        len_prefix: bytes = len(message).to_bytes(2, "big")  # 定长为2字节 大端序 建立字节流对象
        data_to_hide: bytes = len_prefix + message.encode()  # 拼接字节流对象

紧接着通过上下文管理器启动图像:

1
2
3
4
5
6
7
8
        raw_file: Path = list(INPUT_PATH.glob("*.png"))[0]
        with Image.open(raw_file) as img:
            pixels = img.load()
            width, height = img.size

            do what?

            img.save(OUTPUT_PATH / "result.png")

do what? 核心部分来了。

1
2
3
4
5
6
7
            for index, bit in enumerate(generate_bit_flow(data_to_hide)):
                origin_channels = list(pixels[(index % width, index // width)])
                origin_channels[CHANNEL_INDEX] = set_lowest_to(
                    bit,
                    origin_channels[CHANNEL_INDEX]
                )
                pixels[(index % width, index // width)] = tuple(origin_channels)
  • 通过enumerate(generate_bit_flow(data_to_hide))同时获取单个比特: int # Literal[0, 1]及其下标。
  • 计算坐标(index // width, index % width),提取出像素并转换为列表。这里利用了列表可变的特性来避免不必要的赋值。
  • 使用set_lowest_to()函数调整某个通道。蓝色对人眼来说最不敏感,因而CHANNEL_INDEX的取值为2,代表蓝色通道。
  • 重灌入像素。

整个函数总览如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
    @staticmethod
    def encode(message: str):

        len_prefix: bytes = len(message).to_bytes(2, "big")  # 定长为2字节 大端序 建立字节流对象
        data_to_hide: bytes = len_prefix + message.encode()  # 拼接字节流对象

        raw_file: Path = list(INPUT_PATH.glob("*.png"))[0]
        with Image.open(raw_file) as img:
            pixels = img.load()
            width, height = img.size

            for index, bit in enumerate(generate_bit_flow(data_to_hide)):
                origin_channels = list(pixels[(index % width, index // width)])
                origin_channels[CHANNEL_INDEX] = set_lowest_to(
                    bit,
                    origin_channels[CHANNEL_INDEX]
                )
                pixels[(index % width, index // width)] = tuple(origin_channels)

            img.save(OUTPUT_PATH / "result.png")

至此,我们将包含长度信息在内的比特流写进了图像的最顶上几行。

4. 读取操作的代码实现


依旧先启动图片。

1
2
3
4
5
6
7
8
9
10
11
    @staticmethod
    def decode():
        result_file = list(OUTPUT_PATH.glob("*.png"))[0]

        with Image.open(result_file) as img:
            pixels = img.load()
            width, height = img.size

			do what?
			
            print(f"{message = }")

do what?

长度信息是固定的16个比特,因此:

1
2
3
4
5
6
7
8
9
            buffer = []
            for index in range(16):
                origin_channels = pixels[(index % width, index // width)]
                lowest = get_lowest(origin_channels[CHANNEL_INDEX])
                buffer.append(lowest)
            binary_str = "".join(
                [str(bit) for bit in buffer]
            )
            length = int(binary_str, 2)  # 字节数

初始化一个空列表作为缓冲区,读取图像的前16个像素最低位并直接将其推入列表。紧接着,将列表转化为二进制字符串。二进制字符串可直接交由int的构造方法进行解读,得出正确的字节数。

已经知道长度后,可故技重施将文本对应的比特流也读入buffer。

1
2
3
4
5
6
7
8
            buffer.clear()
            for index in range(16, 16+length*8):
                origin_channels = pixels[(index % width, index // width)]
                lowest = get_lowest(origin_channels[CHANNEL_INDEX])
                buffer.append(lowest)
            binary_str = "".join(
                [str(bit) for bit in buffer]
            )

我们又一次得到了二进制字符串对象。与整数的解读所不同的是,现在每八位指代一个整数,这一0~255之间的整数直接映射到ASCII中的字符。我们需要每八位截取一次,将其转译回整数,将其组合为list[int],传入bytes的构造方法生成字节流对象。

1
2
3
4
5
6
7
8
            bytes_list = []  # 从比特流中恢复字节项
            for byte_head_index in range(0, len(binary_str), 8):
                byte = int(binary_str[byte_head_index:byte_head_index+8], 2)
                bytes_list.append(byte)

            bytes_data = bytes(bytes_list)  # 从字节项列表生成字节流对象
            message = bytes_data.decode()  # 解码
            print(f"{message = }")

至此,message已被转译出。

5. 结语


通过上述实现,我们成功构建了一个基础的LSB隐写工具。它能够将任意ASCII文本信息嵌入到PNG图像的蓝色通道最低位中,并在需要时无损提取。整个过程对图像视觉质量的影响微乎其微,充分体现了LSB技术“隐于无形”的特点。

项目源代码复制粘贴如下,另附一个维吉尼亚密码小工具。

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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
from pathlib import Path
from typing import Literal, Generator, Any

from PIL import Image

INPUT_PATH = Path(r'./input')
OUTPUT_PATH = Path(r'./output')
CHANNEL_INDEX = 2  # 蓝色

assert INPUT_PATH.exists(), f'input path {INPUT_PATH} does not exist'
assert OUTPUT_PATH.exists(), f'output path {OUTPUT_PATH} does not exist'

def set_lowest_to(target: Literal[0,1], num: int) -> int:
    """
    修改一个整数的二进制最低位
    :param target: 最低位需求
    :param num: 原整数
    :return: 修改后的整数
    """
    match target:
        case 0:
            return num & -2
        case 1:
            return num | 1
        case _:
            raise ValueError

def get_lowest(num: int) -> int:
    """
    取得一个整数的最低位
    :param num: 需检测的整数
    :return: 二进制最低位
    """
    return num % 2

def generate_bit_flow(data: bytes) -> Generator[Literal[0,1], Any, None]:
    """
    从字节流对象生成比特流
    :param data: 字节流对象
    :return: 比特流的生成器
    """
    for _byte in data:
        for _bit in format(_byte, "08b"):
            yield int(_bit)

class CryptographyTools:

    @staticmethod
    def encode(message: str, key: str):
        out = []
        key_len = len(key)
        for index in range(len(message)):
            out.append(
                chr((ord(message[index]) + ord(key[index % key_len]))%128)
            )
        return "".join(out)

    @staticmethod
    def decode(message: str, key: str):
        out = []
        key_len = len(key)
        for index in range(len(message)):
            out.append(
                chr((ord(message[index]) - ord(key[index % key_len]))%128)
            )
        return "".join(out)

class SteganographyManager:

    def __init__(self):
        ...

    @staticmethod
    def encode(message: str):

        len_prefix: bytes = len(message).to_bytes(2, "big")  # 定长为2字节 大端序 建立字节流对象
        data_to_hide: bytes = len_prefix + message.encode()  # 拼接字节流对象

        require_pixels = len(data_to_hide) * 8  # 需要的像素数
        print(f"{require_pixels = }")

        raw_file: Path = list(INPUT_PATH.glob("*.png"))[0]
        with Image.open(raw_file) as img:
            pixels = img.load()
            width, height = img.size

            for index, bit in enumerate(generate_bit_flow(data_to_hide)):  # 逐像素处理
                origin_channels = list(pixels[(index % width, index // width)])
                origin_channels[CHANNEL_INDEX] = set_lowest_to(
                    bit,
                    origin_channels[CHANNEL_INDEX]
                )
                pixels[(index % width, index // width)] = tuple(origin_channels)

            img.save(OUTPUT_PATH / "result.png")
        print("编码已完成")

    @staticmethod
    def decode():
        encoded_img = list(OUTPUT_PATH.glob("*.png"))[0]

        with Image.open(encoded_img) as img:
            pixels = img.load()
            width, height = img.size

            buffer = []
            for index in range(16):
                origin_channels = pixels[(index % width, index // width)]
                lowest = get_lowest(origin_channels[CHANNEL_INDEX])
                buffer.append(lowest)
            binary_str = "".join(
                [str(bit) for bit in buffer]
            )
            length = int(binary_str, 2)  # 字节数

            buffer.clear()
            for index in range(16, 16+length*8):
                origin_channels = pixels[(index % width, index // width)]
                lowest = get_lowest(origin_channels[CHANNEL_INDEX])
                buffer.append(lowest)
            binary_str = "".join(
                [str(bit) for bit in buffer]
            )
            bytes_list = []  # 从比特流中恢复字节项
            for byte_head_index in range(0, len(binary_str), 8):
                byte = int(binary_str[byte_head_index:byte_head_index+8], 2)
                bytes_list.append(byte)

            bytes_data = bytes(bytes_list)  # 从字节项列表生成字节流对象
            message = bytes_data.decode()  # 解码

            print("解码已完成")
            return message


if __name__ == '__main__':
    with open(INPUT_PATH / "input.txt", "r", encoding="utf-8") as f:
        SteganographyManager.encode(
            CryptographyTools.encode(
                f.read(),
                "Key"
            )
        )

    with open(OUTPUT_PATH / "result.txt", "w", encoding="utf-8") as f:
        f.write(
            CryptographyTools.decode(
                SteganographyManager.decode(),
                "Key"
            )
        )