Skip to content

Commit 49cdf32

Browse files
fix: [wav] embed message bytes in the true LSB of each sample
Two fixes to the wav module, sharing the format break already introduced for LSB images in the previous commit: 1. Unicode corruption: like the LSB image code, hide() converted each character to a fixed-width bit string via ord(), silently corrupting code points above U+00FF under UTF-8 ("🔥🔥🔥" revealed as 'ú\x92ý'). The message is now embedded as its encoded bytes and the 8-bit length prefix stores the byte count (up to 255 bytes) instead of the character count. 2. Per-byte embedding: hide() set the LSB of every byte of the raw frame data, ignoring sampwidth. On 16-bit PCM every second message bit landed in bit 8 of a sample, changing amplitudes by up to +-257 (audible and trivially detectable). Bits are now written only to the least significant byte of each little-endian sample, so samples change by at most +-1. The capacity check counts samples, no longer bytes. Also validate the encoding name (ValueError) and re-raise a decode failure during reveal as IndexError, matching the LSB behaviour. BREAKING CHANGE (on-file format): messages hidden in WAV files with Stegano <= 2.5.0 are only readable by this version if the message is pure ASCII with the UTF-8 encoding and the carrier has 8-bit samples; anything else must be hidden again. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 0486c57 commit 49cdf32

3 files changed

Lines changed: 121 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,18 @@
1313
pure ASCII text with the UTF-8 encoding (that case is bit-identical).
1414
Non-ASCII UTF-8 messages and all UTF-32LE messages use a different bit
1515
layout and must be hidden again with the new version.
16+
- Fixed the same Unicode corruption in the wav module: the message is now
17+
embedded as its encoded bytes, and the 8-bit length prefix stores the
18+
length of the message in bytes (up to 255) instead of characters.
19+
- Fixed the wav module modifying the least significant bit of every *byte*
20+
of the frame data instead of every sample. On 16-bit PCM carriers this
21+
changed sample amplitudes by up to ±257 (clearly audible and trivially
22+
detectable); samples now change by at most ±1.
23+
- **Breaking change**: because of the two fixes above, the wav bit layout
24+
changed as well. Messages hidden in WAV files with Stegano <= 2.5.0 can
25+
only be revealed by this version if the message is pure ASCII with the
26+
UTF-8 encoding *and* the carrier uses 8-bit samples; anything else must
27+
be hidden again with the new version.
1628

1729

1830
### 2.5.0 (2026-07-10)

stegano/wav/wav.py

Lines changed: 43 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,15 @@ def hide(
3838
"""
3939
Hide a message (string) in a .wav audio file.
4040
41-
Use the lsb of each PCM encoded sample to hide the message string characters as ASCII values.
42-
The first eight bits are used for message_length of the string.
41+
Use the LSB of each PCM encoded sample to hide the message bytes.
42+
The first eight bits are used for the length of the message in bytes.
4343
"""
44-
message_length = len(message)
45-
assert message_length != 0, "message message_length is zero"
46-
assert message_length < 255, "message is too long"
44+
if encoding not in tools.ENCODINGS:
45+
raise ValueError(f"Unsupported encoding: {encoding}")
46+
47+
assert len(message) != 0, "message message_length is zero"
48+
message_bytes = message.encode(encoding)
49+
assert len(message_bytes) < 256, "message is too long"
4750

4851
output = wave.open(output_file, "wb")
4952
with wave.open(input_file, "rb") as input:
@@ -53,8 +56,11 @@ def hide(
5356

5457
nsamples = nframes * nchannels
5558

56-
message_bits = f"{message_length:08b}" + "".join(
57-
tools.a2bits_list(message, encoding)
59+
# The payload is the byte-length prefix (8 bits) followed by the
60+
# message encoded to bytes, 8 bits per byte.
61+
message_bits = "".join(
62+
bin(byte)[2:].rjust(8, "0")
63+
for byte in bytes([len(message_bytes)]) + message_bytes
5864
)
5965
assert len(message_bits) <= nsamples, "message is too long"
6066

@@ -63,50 +69,50 @@ def hide(
6369
output.setsampwidth(sampwidth)
6470
output.setframerate(framerate)
6571

66-
# encode message in frames
67-
frames = bytearray(input.readframes(nsamples))
68-
for i in range(nsamples):
69-
if i < len(message_bits):
70-
if message_bits[i] == "0":
71-
frames[i] = frames[i] & ~1
72-
else:
73-
frames[i] = frames[i] | 1
72+
# Encode one bit per sample. PCM samples wider than one byte are
73+
# little-endian: the LSB of sample i is bit 0 of frames[i * sampwidth].
74+
frames = bytearray(input.readframes(nframes))
75+
for i, bit in enumerate(message_bits):
76+
if bit == "0":
77+
frames[i * sampwidth] = frames[i * sampwidth] & ~1
78+
else:
79+
frames[i * sampwidth] = frames[i * sampwidth] | 1
7480

7581
# write out
7682
output.writeframes(frames)
7783

7884

7985
def reveal(input_file: Union[str, IO[bytes]], encoding: str = "UTF-8"):
8086
"""
81-
Find a message in an image.
87+
Find a message in a .wav audio file.
8288
83-
Check the lsb of each PCM encoded sample for hidden message characters (ASCII values).
84-
The first eight bits are used for message_length of the string.
89+
Check the LSB of each PCM encoded sample for the hidden message bytes.
90+
The first eight bits are used for the length of the message in bytes.
8591
"""
86-
message = ""
87-
encoding_len = tools.ENCODINGS[encoding]
92+
if encoding not in tools.ENCODINGS:
93+
raise ValueError(f"Unsupported encoding: {encoding}")
94+
8895
with wave.open(input_file, "rb") as input:
89-
nchannels, _, _, nframes, comptype, _ = input.getparams()
96+
_, sampwidth, _, nframes, comptype, _ = input.getparams()
9097
assert comptype == "NONE", "only uncompressed files are supported"
9198

92-
nsamples = nframes * nchannels
93-
frames = bytearray(input.readframes(nsamples))
99+
frames = bytearray(input.readframes(nframes))
94100

95-
# Read first 8 bits for message length
101+
# Read first 8 bits for the message length in bytes
96102
length_bits = ""
97103
for i in range(8):
98-
length_bits += str(frames[i] & 1)
104+
length_bits += str(frames[i * sampwidth] & 1)
99105
message_length = int(length_bits, 2)
100106

101-
# Read message bits
102-
message_bits = ""
103-
for i in range(8, 8 + message_length * encoding_len):
104-
message_bits += str(frames[i] & 1)
105-
106-
# Convert bits to string
107-
chars = [
108-
chr(int(message_bits[i : i + encoding_len], 2))
109-
for i in range(0, len(message_bits), encoding_len)
110-
]
111-
message = "".join(chars)
112-
return message
107+
# Read the message bytes
108+
message_bytes = bytearray()
109+
for i in range(message_length):
110+
byte_bits = ""
111+
for j in range(8):
112+
byte_bits += str(frames[(8 + i * 8 + j) * sampwidth] & 1)
113+
message_bytes.append(int(byte_bits, 2))
114+
115+
try:
116+
return bytes(message_bytes).decode(encoding)
117+
except UnicodeDecodeError as exc:
118+
raise IndexError("Impossible to detect message.") from exc

tests/test_wav.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,10 @@
2222
__date__ = "$Date: 2016/05/19 $"
2323
__license__ = "GPLv3"
2424

25+
import array
2526
import os
2627
import unittest
28+
import wave
2729

2830
from stegano import wav
2931

@@ -47,6 +49,61 @@ def test_hide_and_reveal(self):
4749

4850
self.assertEqual(message, clear_message)
4951

52+
def test_hide_and_reveal_UTF8_unicode(self):
53+
messages_to_hide = ["héllo wörld 🔥", "🍕🍕🍕", "café crème"]
54+
55+
for message in messages_to_hide:
56+
wav.hide(
57+
"./tests/sample-files/free-software-song.wav", message, "./audio.wav"
58+
)
59+
clear_message = wav.reveal("./audio.wav")
60+
61+
self.assertEqual(message, clear_message)
62+
63+
def test_hide_and_reveal_UTF32LE(self):
64+
message = "I love 🍕 and 🍫!"
65+
wav.hide(
66+
"./tests/sample-files/free-software-song.wav",
67+
message,
68+
"./audio.wav",
69+
encoding="UTF-32LE",
70+
)
71+
clear_message = wav.reveal("./audio.wav", encoding="UTF-32LE")
72+
73+
self.assertEqual(message, clear_message)
74+
75+
def test_sample_distortion(self):
76+
"""
77+
Hiding a message must only change the least significant bit of the
78+
samples: on a 16-bit carrier every sample may change by at most 1.
79+
"""
80+
wav.hide(
81+
"./tests/sample-files/free-software-song.wav",
82+
"Hello World!",
83+
"./audio.wav",
84+
)
85+
86+
with wave.open("./tests/sample-files/free-software-song.wav", "rb") as f:
87+
original = array.array("h", f.readframes(f.getnframes()))
88+
with wave.open("./audio.wav", "rb") as f:
89+
encoded = array.array("h", f.readframes(f.getnframes()))
90+
91+
max_delta = max(abs(a - b) for a, b in zip(original, encoded))
92+
self.assertLessEqual(max_delta, 1)
93+
94+
def test_with_unsupported_encoding(self):
95+
with self.assertRaises(ValueError):
96+
wav.hide(
97+
"./tests/sample-files/free-software-song.wav",
98+
"Hello",
99+
"./audio.wav",
100+
encoding="latin-1",
101+
)
102+
with self.assertRaises(ValueError):
103+
wav.reveal(
104+
"./tests/sample-files/free-software-song.wav", encoding="latin-1"
105+
)
106+
50107
def test_with_too_long_message(self):
51108
with open("./tests/sample-files/lorem_ipsum.txt") as f:
52109
message = f.read()
@@ -55,6 +112,15 @@ def test_with_too_long_message(self):
55112
"./tests/sample-files/free-software-song.wav", message, "./audio.wav"
56113
)
57114

115+
def test_with_message_over_byte_limit(self):
116+
# 64 four-byte characters exceed the 255-byte capacity of the
117+
# 8-bit length prefix even though the character count is small.
118+
message = "🔥" * 64
119+
with self.assertRaises(AssertionError):
120+
wav.hide(
121+
"./tests/sample-files/free-software-song.wav", message, "./audio.wav"
122+
)
123+
58124
def tearDown(self):
59125
try:
60126
os.unlink("./audio.wav")

0 commit comments

Comments
 (0)