fix(ext): bound covert payload processing

Harden LSB extraction against untrusted lengths, insufficient carrier
capacity, truncated payloads, and unbounded stream consumption.

Validate JPEG/EXIF covert framing, configured slot capacity, duplicate
slots, Pack7 lengths, payload completeness, and the one MiB payload
limit.

Add focused boundary and malformed-input tests and document the
incubating JPEG parser trust boundary and accepted residual risk.
This commit is contained in:
2026-07-31 00:09:33 +02:00
parent 54c6c3dd47
commit 369c626ed8
4 changed files with 838 additions and 80 deletions

View File

@@ -33,6 +33,8 @@
******************************************************************************/
package zeroecho.ext.integrations.covert.jpeg;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
@@ -40,10 +42,9 @@ import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Base64;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.commons.imaging.Imaging;
import org.apache.commons.imaging.ImagingException;
@@ -57,7 +58,6 @@ import org.apache.commons.imaging.formats.tiff.write.TiffOutputField;
import org.apache.commons.imaging.formats.tiff.write.TiffOutputSet;
import zeroecho.core.io.Util;
import zeroecho.sdk.util.Pack7LStreamWriter;
/**
* Utility class for embedding and extracting binary payloads across multiple
@@ -72,19 +72,43 @@ import zeroecho.sdk.util.Pack7LStreamWriter;
* The original JPEG EXIF metadata is preserved, except for any overwritten
* fields defined via slot configuration.
* </p>
*
* <p>
* This is an incubating integration, not a general-purpose parser for hostile
* JPEG input. Structural JPEG and TIFF/EXIF parsing is delegated to Apache
* Commons Imaging. This class independently validates its covert Pack7 framing,
* configured slot order and capacity, duplicate slots, payload completeness, and
* the one-MiB covert-payload limit. Successful parsing by the underlying library
* does not guarantee rejection of every malformed JPEG marker or APP1 boundary.
* Callers accepting fully untrusted images should perform separate structural
* validation or process them in an appropriate sandbox.
* </p>
*
* <p>
* No partial covert payload is written when the declared ZeroEcho payload is
* incomplete. Covert-payload memory is bounded by configured slot capacity and
* one MiB, subject to the independent resource behavior of Apache Commons
* Imaging while parsing the carrier. The existing EXIF-slot and Pack7 on-wire
* representation is unchanged.
* </p>
*/
public final class JpegExifEmbedder {
private static final Logger LOG = Logger.getLogger(JpegExifEmbedder.class.getName());
/* package */ static final int MAXIMUM_PAYLOAD_BYTES = 1024 * 1024;
private static final int MAXIMUM_PACKED_PREFIX_BYTES = 10;
private final List<Slot> slots = new ArrayList<>();
/**
* Configures the list of EXIF slots to be used for payload embedding and
* extraction. The order determines the splitting/joining order of the payload.
* Slots must be unique and have positive capacities.
*
* @param slots List of slot configurations
* @param slots nonempty list of positive-capacity slot configurations
* @throws IllegalArgumentException if the list is empty or contains a
* {@code null} or nonpositive-capacity slot
*/
public void setSlots(List<Slot> slots) {
requireValidSlots(slots);
this.slots.clear();
this.slots.addAll(slots);
}
@@ -100,6 +124,12 @@ public final class JpegExifEmbedder {
* stream using a lossless EXIF update.
* </p>
*
* <p>
* Apache Commons Imaging owns structural JPEG and TIFF/EXIF parsing. This
* method limits the covert payload to one MiB and configured slot capacity,
* but it is not an independent validator for arbitrary outer JPEG corruption.
* </p>
*
* @param jpegPath the path to the input JPEG file; must not be {@code null}
* @param payloadInput the input stream containing the binary payload to embed;
* it must be prefixed using 7-bit length encoding (e.g.
@@ -107,28 +137,38 @@ public final class JpegExifEmbedder {
* @param jpegOutput the output stream where the modified JPEG will be written
* @return the total number of bytes read from {@code payloadInput}, including
* the prefix
* @throws IOException if an I/O error occurs while reading the payload,
* processing EXIF metadata, or writing the output JPEG
* @throws IOException if an I/O error occurs, the payload exceeds
* one MiB or configured slot capacity, or EXIF
* processing fails
* @throws IllegalStateException if no slots have been configured
*/
public int embed(Path jpegPath, InputStream payloadInput, OutputStream jpegOutput) throws IOException {
requireConfiguredSlots();
int maximumPayloadBytes = maximumPayloadBytesForSlots();
byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, maximumPayloadBytes);
long totalCapacity = totalRawCapacity();
if (allBytes.length > totalCapacity) {
throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
}
try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
TiffOutputSet outputSet = (exif != null) ? exif.getOutputSet() : new TiffOutputSet(ByteOrder.BIG_ENDIAN);
byte[] allBytes = Util.readWithPackedLengthPrefix(payloadInput, 1024 * 1024);
int offset = 0;
for (Slot slot : slots) {
if (offset >= allBytes.length) {
break;
}
boolean useBase64 = slot.tagInfo.isText();
int chunkLimit = slot.defaultCapacity;
int chunkLimit = rawCapacity(slot);
byte[] encoded;
if (useBase64) {
// base64 expands ~33%, so reduce raw chunk size
int safeRawSize = chunkLimit * 3 / 4;
int size = Math.min(safeRawSize, allBytes.length - offset);
int size = Math.min(chunkLimit, allBytes.length - offset);
byte[] raw = new byte[size]; // NOPMD
System.arraycopy(allBytes, offset, raw, 0, size);
offset += size;
@@ -160,11 +200,11 @@ public final class JpegExifEmbedder {
directory.removeField(slot.tagInfo); // Remove only if collides
directory.add(field);
if (offset >= allBytes.length) {
break;
}
}
if (offset != allBytes.length) {
throw new IOException("JPEG EXIF payload exceeds configured slot capacity.");
}
new ExifRewriter().updateExifMetadataLossless(jpegPath.toFile(), jpegOutput, outputSet);
return allBytes.length;
@@ -177,51 +217,218 @@ public final class JpegExifEmbedder {
* Extracts a binary payload that was previously embedded into the JPEG via EXIF
* slots.
*
* <p>
* Apache Commons Imaging parses the carrier structure. ZeroEcho validates the
* selected EXIF fields, Pack7 length, configured capacity, and complete
* availability of the declared covert payload before writing caller output.
* Outer marker and APP1 corruption accepted by the underlying parser is outside
* this method's independent guarantees.
* </p>
*
* @param jpegPath input JPEG path
* @param payloadOutput stream to write the reconstructed binary payload
* @throws IOException if the extraction fails
* @throws EOFException if the packed length prefix or declared
* payload is incomplete
* @throws IOException if supported carrier parsing fails or the
* payload length or configured slot data is
* malformed or exceeds one MiB
* @throws IllegalStateException if no slots have been configured
*/
public void extract(Path jpegPath, OutputStream payloadOutput) throws IOException {
requireConfiguredSlots();
try {
ImageMetadata metadata = Imaging.getMetadata(jpegPath.toFile());
TiffImageMetadata exif = (metadata instanceof JpegImageMetadata jmeta) ? jmeta.getExif() : null;
if (exif == null) {
LOG.warning("EXIF metadata not found in image.");
return;
throw new EOFException("JPEG EXIF covert payload is missing.");
}
Pack7LStreamWriter output = new Pack7LStreamWriter(payloadOutput);
PackedPayloadCollector collector = new PackedPayloadCollector();
for (Slot slot : slots) {
TiffField field = exif.findField(slot.tagInfo);
if (field == null) {
continue;
}
requireBoundedField(field, slot);
Object value = slot.tagInfo.getValue(field);
byte[] chunk;
if (value instanceof byte[] binary) {
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
} else if (value instanceof String str) {
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(str.getBytes(StandardCharsets.US_ASCII))
: str.getBytes(StandardCharsets.UTF_8);
} else {
LOG.log(Level.WARNING, "Unsupported EXIF field value type for tag: {0}", slot.tagInfo.name);
continue;
byte[] chunk = decodeChunk(value, slot);
if (chunk.length > rawCapacity(slot)) {
throw new IOException("JPEG EXIF covert chunk exceeds slot capacity.");
}
if (chunk.length > output.write(chunk)) {
// all data has been read
collector.accept(chunk);
if (collector.isComplete()) {
break;
}
}
byte[] payload = collector.finish();
payloadOutput.write(payload);
} catch (ImagingException e) {
throw new IOException("Failed to read EXIF from JPEG", e);
}
}
private void requireConfiguredSlots() {
if (slots.isEmpty()) {
throw new IllegalStateException("JPEG EXIF slots are not configured.");
}
}
private static void requireValidSlots(List<Slot> configuredSlots) {
if (configuredSlots == null || configuredSlots.isEmpty()) {
throw new IllegalArgumentException("JPEG EXIF slots must not be empty.");
}
for (int index = 0; index < configuredSlots.size(); index++) {
Slot slot = configuredSlots.get(index);
if (slot == null || slot.defaultCapacity <= 0) {
throw new IllegalArgumentException("JPEG EXIF slot capacity must be positive.");
}
if (configuredSlots.subList(0, index).contains(slot)) {
throw new IllegalArgumentException("JPEG EXIF slots must be unique.");
}
}
}
private int maximumPayloadBytesForSlots() throws IOException {
long capacity = totalRawCapacity();
int candidate = (int) Math.min(MAXIMUM_PAYLOAD_BYTES, capacity);
while (candidate > 0 && candidate + packedLengthBytes(candidate) > capacity) {
candidate--;
}
if (packedLengthBytes(candidate) > capacity) {
throw new IOException("JPEG EXIF slot capacity cannot contain a payload prefix.");
}
return candidate;
}
private long totalRawCapacity() throws IOException {
long capacity = 0;
try {
for (Slot slot : slots) {
capacity = Math.addExact(capacity, rawCapacity(slot));
}
return capacity;
} catch (ArithmeticException exception) {
throw new IOException("JPEG EXIF slot capacity is invalid.", exception);
}
}
private static int rawCapacity(Slot slot) {
return slot.tagInfo.isText() ? slot.defaultCapacity / 4 * 3 : slot.defaultCapacity;
}
private static int packedLengthBytes(long value) {
int bytes = 1;
long remaining = value;
while ((remaining & ~0x7fL) != 0) {
remaining >>>= 7;
bytes++;
}
return bytes;
}
private static void requireBoundedField(TiffField field, Slot slot) throws IOException {
final long encodedLength;
try {
encodedLength = Math.multiplyExact(field.getCount(), field.getFieldType().getSize());
} catch (ArithmeticException exception) {
throw new IOException("JPEG EXIF covert field length is invalid.", exception);
}
long maximumStoredBytes = slot.tagInfo.isText()
? Math.min((long) slot.defaultCapacity + 1, encodedLimitForPayload())
: Math.min((long) slot.defaultCapacity, MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES);
if (encodedLength < 0 || encodedLength > maximumStoredBytes) {
throw new IOException("JPEG EXIF covert field exceeds slot capacity.");
}
}
private static long encodedLimitForPayload() {
long rawBytes = MAXIMUM_PAYLOAD_BYTES + MAXIMUM_PACKED_PREFIX_BYTES;
return (rawBytes + 2) / 3 * 4 + 1;
}
private static byte[] decodeChunk(Object value, Slot slot) throws IOException {
byte[] chunk = null;
try {
if (value instanceof byte[] binary) {
chunk = slot.tagInfo.isText() ? Base64.getDecoder().decode(binary) : binary;
} else if (value instanceof String string) {
chunk = slot.tagInfo.isText()
? Base64.getDecoder().decode(string.getBytes(StandardCharsets.US_ASCII))
: string.getBytes(StandardCharsets.UTF_8);
}
} catch (IllegalArgumentException exception) {
chunk = null;
}
if (chunk == null) {
throw new IOException("JPEG EXIF covert chunk is malformed.");
}
return chunk;
}
/* package */ static byte[] decodePayloadChunks(List<byte[]> chunks) throws IOException {
PackedPayloadCollector collector = new PackedPayloadCollector();
for (byte[] chunk : chunks) {
collector.accept(chunk);
if (collector.isComplete()) {
break;
}
}
return collector.finish();
}
/**
* Accumulates one bounded Pack7-framed payload across configured EXIF slots.
*/
private static final class PackedPayloadCollector {
private final byte[] prefix = new byte[MAXIMUM_PACKED_PREFIX_BYTES];
private int prefixLength;
private byte[] payload;
private int payloadLength;
/* package */ void accept(byte[] chunk) throws IOException {
int offset = 0;
boolean prefixComplete = false;
while (payload == null && offset < chunk.length) {
if (prefixLength == prefix.length) {
throw new IOException("JPEG EXIF packed payload length is malformed.");
}
byte current = chunk[offset++];
prefix[prefixLength++] = current;
if ((current & 0x80) != 0) {
prefixComplete = true;
break;
}
}
if (prefixComplete) {
long declaredLength = Util.readPack7L(new ByteArrayInputStream(prefix, 0, prefixLength));
if (declaredLength < 0 || declaredLength > MAXIMUM_PAYLOAD_BYTES) {
throw new IOException("JPEG EXIF payload length exceeds maximum.");
}
payload = new byte[(int) declaredLength];
}
if (payload != null && offset < chunk.length && payloadLength < payload.length) {
int remaining = payload.length - payloadLength;
int copied = Math.min(remaining, chunk.length - offset);
System.arraycopy(chunk, offset, payload, payloadLength, copied);
payloadLength += copied;
}
}
/* package */ boolean isComplete() {
return payload != null && payloadLength == payload.length;
}
/* package */ byte[] finish() throws EOFException {
if (payload == null) {
throw new EOFException("JPEG EXIF payload length prefix is truncated.");
}
if (payloadLength != payload.length) {
throw new EOFException("JPEG EXIF payload is truncated.");
}
return Arrays.copyOf(payload, payload.length);
}
}
}

View File

@@ -36,10 +36,9 @@ package zeroecho.ext.integrations.stegano;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.util.Locale;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -99,6 +98,9 @@ import javax.imageio.ImageIO;
* }</pre>
*/
public class LSBSteganographyMethod implements SteganographyMethod {
private static final int LENGTH_PREFIX_BYTES = Integer.BYTES;
private static final int LENGTH_PREFIX_BITS = LENGTH_PREFIX_BYTES * Byte.SIZE;
private static final int CARRIER_BITS_PER_PAYLOAD_BYTE = Byte.SIZE;
private static final Logger LOG = Logger.getLogger(LSBSteganographyMethod.class.getName());
/**
@@ -118,9 +120,9 @@ public class LSBSteganographyMethod implements SteganographyMethod {
* @param message the message stream to embed
* @return an {@code InputStream} containing the stego image encoded in
* {@code outputFormat}
* @throws IOException if an I/O error occurs while reading the
* image or message, or while writing the
* output image
* @throws IOException if an I/O error occurs, the carrier is too
* small, or the message exceeds the carrier's
* bounded capacity
* @throws IllegalArgumentException if {@code outputFormat} is unsupported for
* this method (for example, {@code JPEG})
*/
@@ -135,15 +137,14 @@ public class LSBSteganographyMethod implements SteganographyMethod {
throw new IOException("Failed to read input image.");
}
byte[] messageBytes = message.readAllBytes();
ByteArrayOutputStream fullData = new ByteArrayOutputStream();
DataOutputStream dos = new DataOutputStream(fullData);
dos.writeInt(messageBytes.length);
dos.write(messageBytes);
byte[] fullMessage = fullData.toByteArray();
final int width = img.getWidth();
final int height = img.getHeight();
final int maximumPayloadBytes = maximumPayloadBytes(width, height);
byte[] messageBytes = message.readNBytes(maximumPayloadBytes + 1);
if (messageBytes.length > maximumPayloadBytes) {
throw new IOException("LSB payload exceeds carrier capacity.");
}
final long requiredBits = requiredCarrierBits(messageBytes.length);
int msgBitIndex = 0;
if (LOG.isLoggable(Level.INFO)) {
@@ -152,13 +153,13 @@ public class LSBSteganographyMethod implements SteganographyMethod {
outer: for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
if (msgBitIndex >= fullMessage.length * 8) {
if (msgBitIndex >= requiredBits) {
break outer;
}
int rgb = img.getRGB(x, y);
int gray = rgb & 0xFF;
int bit = (fullMessage[msgBitIndex / 8] >> (7 - (msgBitIndex % 8))) & 1;
int bit = embeddedBit(messageBytes, msgBitIndex);
gray = (gray & 0xFE) | bit;
int newRgb = (gray << 16) | (gray << 8) | gray;
img.setRGB(x, y, newRgb);
@@ -185,8 +186,10 @@ public class LSBSteganographyMethod implements SteganographyMethod {
*
* @param inputImage the stego image stream that contains an embedded message
* @return an {@code InputStream} of the extracted message bytes
* @throws IOException if an I/O error occurs while reading the image or if the
* image cannot be decoded
* @throws EOFException if the carrier cannot contain the complete prefix or
* declared payload
* @throws IOException if the image cannot be decoded or the embedded length
* is negative or outside the carrier's bounded capacity
*/
@Override
public InputStream extract(InputStream inputImage) throws IOException {
@@ -197,42 +200,38 @@ public class LSBSteganographyMethod implements SteganographyMethod {
final int width = img.getWidth();
final int height = img.getHeight();
ByteArrayOutputStream messageBytes = new ByteArrayOutputStream();
final long carrierBits = carrierBits(width, height);
if (carrierBits < LENGTH_PREFIX_BITS) {
throw new EOFException("LSB carrier length prefix is truncated.");
}
if (LOG.isLoggable(Level.INFO)) {
LOG.log(Level.INFO, "extract from picture w={0} x h={1}", new Object[] { width, height });
}
int byteVal = 0;
int messageLength = -1;
int bitsCollected = 0;
outer: for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int gray = img.getRGB(x, y) & 0xFF;
int bit = gray & 1;
byteVal = (byteVal << 1) | bit;
bitsCollected++;
if (bitsCollected == 8) { // NOPMD
messageBytes.write(byteVal);
byteVal = 0;
bitsCollected = 0;
if (messageLength == -1 && messageBytes.size() == 4) {
byte[] lenBytes = messageBytes.toByteArray();
messageBytes.reset();
messageLength = ByteBuffer.wrap(lenBytes).getInt();
}
if (messageLength != -1 && messageBytes.size() == messageLength) {
break outer;
}
}
}
int messageLength = 0;
for (int bitIndex = 0; bitIndex < LENGTH_PREFIX_BITS; bitIndex++) {
messageLength = (messageLength << 1) | carrierBit(img, width, bitIndex);
}
if (messageLength < 0) {
throw new IOException("LSB payload length is invalid.");
}
long requiredBits = requiredCarrierBits(messageLength);
if (requiredBits > carrierBits) {
throw new EOFException("LSB carrier payload is truncated.");
}
return new ByteArrayInputStream(messageBytes.toByteArray());
byte[] messageBytes = new byte[messageLength];
long carrierBitIndex = LENGTH_PREFIX_BITS;
for (int byteIndex = 0; byteIndex < messageLength; byteIndex++) {
int value = 0;
for (int bitIndex = 0; bitIndex < Byte.SIZE; bitIndex++) {
value = (value << 1) | carrierBit(img, width, carrierBitIndex++);
}
messageBytes[byteIndex] = (byte) value;
}
return new ByteArrayInputStream(messageBytes);
}
/**
@@ -250,4 +249,47 @@ public class LSBSteganographyMethod implements SteganographyMethod {
return new StegoMetadata("LSB", "Least Significant Bit Spatial Domain Image Steganography",
"Embeds message bits into the least significant bits of grayscale pixel values.");
}
private static int maximumPayloadBytes(int width, int height) throws IOException {
long availableBits = carrierBits(width, height) - LENGTH_PREFIX_BITS;
if (availableBits < 0) {
throw new IOException("LSB carrier is too small.");
}
long maximumPayloadBytes = availableBits / CARRIER_BITS_PER_PAYLOAD_BYTE;
if (maximumPayloadBytes >= Integer.MAX_VALUE) {
throw new IOException("LSB carrier capacity exceeds supported range.");
}
return (int) maximumPayloadBytes;
}
private static long carrierBits(int width, int height) throws IOException {
try {
return Math.multiplyExact((long) width, (long) height);
} catch (ArithmeticException exception) {
throw new IOException("LSB carrier capacity is invalid.", exception);
}
}
private static long requiredCarrierBits(int payloadBytes) throws IOException {
try {
return Math.addExact(LENGTH_PREFIX_BITS,
Math.multiplyExact((long) payloadBytes, CARRIER_BITS_PER_PAYLOAD_BYTE));
} catch (ArithmeticException exception) {
throw new IOException("LSB payload capacity is invalid.", exception);
}
}
private static int embeddedBit(byte[] message, int bitIndex) {
if (bitIndex < LENGTH_PREFIX_BITS) {
return (message.length >>> (LENGTH_PREFIX_BITS - 1 - bitIndex)) & 1;
}
int payloadBit = bitIndex - LENGTH_PREFIX_BITS;
return (message[payloadBit / Byte.SIZE] >>> (Byte.SIZE - 1 - payloadBit % Byte.SIZE)) & 1;
}
private static int carrierBit(BufferedImage image, int width, long bitIndex) {
int x = (int) (bitIndex % width);
int y = (int) (bitIndex / width);
return image.getRGB(x, y) & 1;
}
}

View File

@@ -0,0 +1,312 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.ext.integrations.covert.jpeg;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import zeroecho.core.io.Util;
class JpegExifEmbedderLengthTest {
@TempDir
Path temporaryDirectory;
@Test
void validPackedPayloadBoundariesDecodeExactly() throws Exception {
System.out.println("validPackedPayloadBoundariesDecodeExactly");
int[] lengths = { 0, 1, 127, 128, 16_384, JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES };
for (int length : lengths) {
byte[] payload = payload(length);
byte[] framed = frame(payload);
List<byte[]> chunks = split(framed, 1, Math.min(97, framed.length));
assertArrayEquals(payload, JpegExifEmbedder.decodePayloadChunks(chunks));
}
System.out.println("...packed boundary lengths: " + lengths.length);
System.out.println("...ok");
}
@Test
void malformedPackedLengthsAndTruncationFailClosed() throws Exception {
System.out.println("malformedPackedLengthsAndTruncationFailClosed");
for (int prefixBytes = 0; prefixBytes < 10; prefixBytes++) {
byte[] incompletePrefix = new byte[prefixBytes];
EOFException incomplete = assertThrows(EOFException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(incompletePrefix)));
assertFalse(incomplete.getMessage().contains("PAYLOAD_SENTINEL"));
}
assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(new byte[10])));
byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
IOException overLimit = assertThrows(IOException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(excessive)));
assertEquals("JPEG EXIF payload length exceeds maximum.", overLimit.getMessage());
byte[] negative = packedLength(-1L);
assertThrows(IOException.class, () -> JpegExifEmbedder.decodePayloadChunks(List.of(negative)));
byte[] illegalHighTerminal = new byte[10];
illegalHighTerminal[0] = 0x02;
illegalHighTerminal[9] = (byte) 0x80;
assertThrows(IOException.class,
() -> JpegExifEmbedder.decodePayloadChunks(List.of(illegalHighTerminal)));
byte[] framed = frame(payload(12));
int prefixLength = packedLength(12).length;
int[] retainedPayloadBytes = { 0, 6, 11 };
for (int retained : retainedPayloadBytes) {
byte[] truncated = Arrays.copyOf(framed, prefixLength + retained);
ByteArrayOutputStream output = new ByteArrayOutputStream();
EOFException incomplete = assertThrows(EOFException.class,
() -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(truncated))));
assertEquals(0, output.size());
assertEquals("JPEG EXIF payload is truncated.", incomplete.getMessage());
}
byte[] surplus = Arrays.copyOf(frame(payload(3)), frame(payload(3)).length + 4);
assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(surplus)));
byte[] firstResult = JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3))));
firstResult[0] ^= 0x7f;
assertArrayEquals(payload(3), JpegExifEmbedder.decodePayloadChunks(List.of(frame(payload(3)))));
System.out.println("...hostile packed cases: 18");
System.out.println("...ok");
}
@Test
void realJpegRoundTripPreservesPayload() throws Exception {
System.out.println("realJpegRoundTripPreservesPayload");
URL carrierResource = Objects.requireNonNull(
getClass().getClassLoader().getResource("test.jpg"));
Path carrier = Path.of(carrierResource.toURI());
Path encoded = temporaryDirectory.resolve("encoded.jpg");
byte[] payload = payload(512);
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(Slot.defaults());
try (ByteArrayOutputStream jpegOutput = new ByteArrayOutputStream()) {
int framedBytes = embedder.embed(carrier, new ByteArrayInputStream(payload), jpegOutput);
Files.write(encoded, jpegOutput.toByteArray());
assertEquals(frame(payload).length, framedBytes);
}
ByteArrayOutputStream extracted = new ByteArrayOutputStream();
embedder.extract(encoded, extracted);
assertArrayEquals(payload, extracted.toByteArray());
System.out.println("...round-trip payload bytes: " + payload.length);
System.out.println("...ok");
}
@Test
void embeddingRejectsOverCapacityBeforeOutput() throws Exception {
System.out.println("embeddingRejectsOverCapacityBeforeOutput");
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(Slot.defaults());
byte[] payload = payload(20_000);
ByteArrayOutputStream output = new ByteArrayOutputStream();
IOException failure = assertThrows(IOException.class,
() -> embedder.embed(temporaryDirectory.resolve("unused.jpg"), new ByteArrayInputStream(payload),
output));
assertEquals(0, output.size());
assertEquals("Input exceeds maximum allowed size: 10238", failure.getMessage());
System.out.println("...rejected payload bytes: " + payload.length);
System.out.println("...ok");
}
@Test
void allConstructionPathsRequireValidSlots() {
System.out.println("allConstructionPathsRequireValidSlots");
JpegExifEmbedder embedder = new JpegExifEmbedder();
Slot duplicate = customRawSlot(50_100, 64);
assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of()));
assertThrows(IllegalArgumentException.class, () -> embedder.setSlots(List.of(duplicate, duplicate)));
assertThrows(IllegalStateException.class,
() -> embedder.extract(temporaryDirectory.resolve("unused.jpg"), new ByteArrayOutputStream()));
System.out.println("...slot validation paths: 3");
System.out.println("...ok");
}
@Test
void parsedExifPayloadBoundariesFailWithoutOutput() throws Exception {
System.out.println("parsedExifPayloadBoundariesFailWithoutOutput");
Slot first = customRawSlot(50_110, 8);
Slot second = customRawSlot(50_111, 64);
byte[] expected = payload(24);
byte[] jpeg = embeddedJpeg(expected, List.of(first, second));
assertExtractionFails(jpeg, List.of(first));
assertArrayEquals(expected, extractPayload(jpeg, List.of(first, second)));
System.out.println("...validated EXIF slots: 2");
System.out.println("...ok");
}
@Test
void hostileExifSlotsFailClosedWithoutPartialOutput() throws Exception {
System.out.println("hostileExifSlotsFailClosedWithoutPartialOutput");
Slot raw = customRawSlot(50_120, 64);
Slot anotherRaw = customRawSlot(50_121, 64);
byte[] validRaw = embeddedJpeg(payload(20), List.of(raw));
assertExtractionFails(validRaw, List.of(anotherRaw));
assertExtractionFails(validRaw, List.of(customRawSlot(50_120, 8)));
Slot first = customRawSlot(50_122, 8);
Slot second = customRawSlot(50_123, 64);
byte[] splitPayload = embeddedJpeg(payload(24), List.of(first, second));
assertExtractionFails(splitPayload, List.of(first));
assertExtractionFails(splitPayload, List.of(second, first));
System.out.println("...bounded EXIF slot cases: 4");
System.out.println("...ok");
}
@Test
void configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput() throws Exception {
System.out.println("configuredCapacityAndMaximumAreEnforcedBeforeCallerOutput");
byte[] excessive = packedLength(JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES + 1L);
ByteArrayOutputStream output = new ByteArrayOutputStream();
assertThrows(IOException.class,
() -> output.write(JpegExifEmbedder.decodePayloadChunks(List.of(excessive))));
assertEquals(0, output.size());
Slot largeText = customTextSlot(50_130, Integer.MAX_VALUE);
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(List.of(largeText));
IOException missingCarrier = assertThrows(IOException.class,
() -> embedder.embed(temporaryDirectory.resolve("missing.jpg"),
new ByteArrayInputStream(payload(1)), new ByteArrayOutputStream()));
assertFalse(missingCarrier.getMessage().contains("PAYLOAD_SENTINEL"));
System.out.println("...maximum payload bytes: " + JpegExifEmbedder.MAXIMUM_PAYLOAD_BYTES);
System.out.println("...ok");
}
private static byte[] payload(int length) {
byte[] payload = new byte[length];
for (int index = 0; index < payload.length; index++) {
payload[index] = (byte) index;
}
return payload;
}
private static byte[] frame(byte[] payload) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7L(output, payload.length);
output.write(payload);
return output.toByteArray();
}
private static byte[] packedLength(long length) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream();
Util.writePack7L(output, length);
return output.toByteArray();
}
private static List<byte[]> split(byte[] value, int firstLength, int nextLength) {
if (value.length <= firstLength) {
return List.of(value);
}
byte[] first = Arrays.copyOfRange(value, 0, firstLength);
byte[] remaining = Arrays.copyOfRange(value, firstLength, value.length);
if (remaining.length <= nextLength) {
return List.of(first, remaining);
}
byte[] second = Arrays.copyOfRange(remaining, 0, nextLength);
byte[] third = Arrays.copyOfRange(remaining, nextLength, remaining.length);
return List.of(first, second, third);
}
private Path carrierPath() throws Exception {
URL carrierResource = Objects.requireNonNull(getClass().getClassLoader().getResource("test.jpg"));
return Path.of(carrierResource.toURI());
}
private byte[] embeddedJpeg(byte[] payload, List<Slot> slots) throws Exception {
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
ByteArrayOutputStream output = new ByteArrayOutputStream();
embedder.embed(carrierPath(), new ByteArrayInputStream(payload), output);
return output.toByteArray();
}
private void assertExtractionFails(byte[] jpeg, List<Slot> slots) throws Exception {
Path path = temporaryDirectory.resolve("hostile-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
Files.write(path, jpeg);
ByteArrayOutputStream output = new ByteArrayOutputStream();
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
IOException failure = assertThrows(IOException.class, () -> embedder.extract(path, output));
assertEquals(0, output.size());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
private byte[] extractPayload(byte[] jpeg, List<Slot> slots) throws Exception {
Path path = temporaryDirectory.resolve("valid-" + Math.abs(Arrays.hashCode(jpeg)) + ".jpg");
Files.write(path, jpeg);
ByteArrayOutputStream output = new ByteArrayOutputStream();
JpegExifEmbedder embedder = new JpegExifEmbedder();
embedder.setSlots(slots);
embedder.extract(path, output);
return output.toByteArray();
}
private static Slot customRawSlot(int tag, int capacity) {
return Slot.parse("EXIF.raw/tag=" + tag + ",undefined,1,exif:" + capacity);
}
private static Slot customTextSlot(int tag, int capacity) {
return Slot.parse("EXIF.text/tag=" + tag + ",ascii,1,exif:" + capacity);
}
}

View File

@@ -0,0 +1,197 @@
/*******************************************************************************
* Copyright (C) 2026, Leo Galambos
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. All advertising materials mentioning features or use of this software must
* display the following acknowledgement:
* This product includes software developed by the Egothor project.
*
* 4. Neither the name of the copyright holder nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR
* ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
* ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
* SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
******************************************************************************/
package zeroecho.ext.integrations.stegano;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import javax.imageio.ImageIO;
import org.junit.jupiter.api.Test;
class LSBSteganographyMethodLengthTest {
private static final int PREFIX_BITS = Integer.SIZE;
@Test
void validBoundaryPayloadsRoundTrip() throws Exception {
System.out.println("validBoundaryPayloadsRoundTrip");
int[] lengths = { 0, 1, 127, 128, 256 };
LSBSteganographyMethod method = new LSBSteganographyMethod();
for (int length : lengths) {
byte[] payload = payload(length);
int carrierBits = PREFIX_BITS + length * Byte.SIZE;
InputStream encoded = method.embed(image(carrierBits + Byte.SIZE), ImageFormat.PNG,
new ByteArrayInputStream(payload));
byte[] decoded = method.extract(encoded).readAllBytes();
assertArrayEquals(payload, decoded);
}
System.out.println("...boundary lengths: " + lengths.length);
System.out.println("...ok");
}
@Test
void exactCapacityAndInsufficientCapacityFailClosed() throws Exception {
System.out.println("exactCapacityAndInsufficientCapacityFailClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
byte[] payload = payload(16);
int exactBits = PREFIX_BITS + payload.length * Byte.SIZE;
InputStream encoded = method.embed(image(exactBits), ImageFormat.PNG, new ByteArrayInputStream(payload));
assertArrayEquals(payload, method.extract(encoded).readAllBytes());
IOException failure = assertThrows(IOException.class,
() -> method.embed(image(exactBits - 1), ImageFormat.PNG, new ByteArrayInputStream(payload)));
assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
System.out.println("...exact carrier bits: " + exactBits);
System.out.println("...ok");
}
@Test
void malformedPrefixesAndTruncatedPayloadsFailClosed() throws Exception {
System.out.println("malformedPrefixesAndTruncatedPayloadsFailClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
assertThrows(IOException.class, () -> method.extract(new ByteArrayInputStream(new byte[0])));
assertThrows(EOFException.class, () -> method.extract(imageWithDeclaredLength(31, 0)));
IOException negative = assertThrows(IOException.class,
() -> method.extract(imageWithDeclaredLength(PREFIX_BITS, -1)));
assertEquals("LSB payload length is invalid.", negative.getMessage());
EOFException truncated = assertThrows(EOFException.class,
() -> method.extract(imageWithDeclaredLength(PREFIX_BITS + Byte.SIZE, 2)));
assertEquals("LSB carrier payload is truncated.", truncated.getMessage());
assertFalse(truncated.getMessage().contains("PAYLOAD_SENTINEL"));
System.out.println("...hostile prefix cases: 4");
System.out.println("...ok");
}
@Test
void everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed() throws Exception {
System.out.println("everyIncompletePrefixAndRepresentativePayloadTruncationFailsClosed");
LSBSteganographyMethod method = new LSBSteganographyMethod();
IOException empty = assertThrows(IOException.class,
() -> method.extract(new ByteArrayInputStream(new byte[0])));
assertFalse(empty.getMessage().contains("PAYLOAD_SENTINEL"));
for (int availablePrefixBits = 1; availablePrefixBits < PREFIX_BITS; availablePrefixBits++) {
int testedPrefixBits = availablePrefixBits;
EOFException failure = assertThrows(EOFException.class,
() -> method.extract(imageWithDeclaredLength(testedPrefixBits, 0)));
assertEquals("LSB carrier length prefix is truncated.", failure.getMessage());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
assertArrayEquals(new byte[0],
method.extract(imageWithDeclaredLength(PREFIX_BITS, 0)).readAllBytes());
int declaredPayloadBytes = 4;
int[] availablePayloadBits = { 0, 16, 31 };
for (int payloadBits : availablePayloadBits) {
EOFException failure = assertThrows(EOFException.class,
() -> method.extract(
imageWithDeclaredLength(PREFIX_BITS + payloadBits, declaredPayloadBytes)));
assertEquals("LSB carrier payload is truncated.", failure.getMessage());
assertFalse(failure.getMessage().contains("PAYLOAD_SENTINEL"));
}
System.out.println("...incomplete prefix positions: 32");
System.out.println("...payload truncation positions: " + availablePayloadBits.length);
System.out.println("...ok");
}
@Test
void overCapacityMessageReadIsDeterministicallyBounded() throws Exception {
System.out.println("overCapacityMessageReadIsDeterministicallyBounded");
LSBSteganographyMethod method = new LSBSteganographyMethod();
CountingInfiniteInputStream message = new CountingInfiniteInputStream();
int payloadCapacity = 8;
IOException failure = assertThrows(IOException.class,
() -> method.embed(image(PREFIX_BITS + payloadCapacity * Byte.SIZE), ImageFormat.PNG, message));
assertEquals("LSB payload exceeds carrier capacity.", failure.getMessage());
assertEquals(payloadCapacity + 1, message.readCount);
System.out.println("...bounded message reads: " + message.readCount);
System.out.println("...ok");
}
private static byte[] payload(int length) {
byte[] payload = new byte[length];
for (int index = 0; index < payload.length; index++) {
payload[index] = (byte) index;
}
return payload;
}
private static InputStream image(int pixels) throws IOException {
BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return new ByteArrayInputStream(output.toByteArray());
}
private static InputStream imageWithDeclaredLength(int pixels, int declaredLength) throws IOException {
BufferedImage image = new BufferedImage(pixels, 1, BufferedImage.TYPE_INT_RGB);
int prefixBits = Math.min(pixels, PREFIX_BITS);
for (int bitIndex = 0; bitIndex < prefixBits; bitIndex++) {
int bit = (declaredLength >>> (PREFIX_BITS - 1 - bitIndex)) & 1;
image.setRGB(bitIndex, 0, bit);
}
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return new ByteArrayInputStream(output.toByteArray());
}
private static final class CountingInfiniteInputStream extends InputStream {
private int readCount;
@Override
public int read() {
readCount++;
return 0x5a;
}
}
}