From 369c626ed8112d3a8883ceeaa3028d081d2477de Mon Sep 17 00:00:00 2001 From: Leo Galambos Date: Fri, 31 Jul 2026 00:09:33 +0200 Subject: [PATCH] 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. --- .../covert/jpeg/JpegExifEmbedder.java | 277 ++++++++++++++-- .../stegano/LSBSteganographyMethod.java | 132 +++++--- .../jpeg/JpegExifEmbedderLengthTest.java | 312 ++++++++++++++++++ .../LSBSteganographyMethodLengthTest.java | 197 +++++++++++ 4 files changed, 838 insertions(+), 80 deletions(-) create mode 100644 ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java create mode 100644 ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java diff --git a/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java b/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java index a593644..b5e48ae 100644 --- a/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java +++ b/ext/src/main/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedder.java @@ -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. *

+ * + *

+ * 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. + *

+ * + *

+ * 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. + *

*/ 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 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 slots) { + requireValidSlots(slots); this.slots.clear(); this.slots.addAll(slots); } @@ -100,6 +124,12 @@ public final class JpegExifEmbedder { * stream using a lossless EXIF update. *

* + *

+ * 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. + *

+ * * @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. * + *

+ * 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. + *

+ * * @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 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 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); + } + } } diff --git a/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java b/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java index 8b1a114..d35facf 100644 --- a/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java +++ b/ext/src/main/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethod.java @@ -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; * } */ 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; + } } diff --git a/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java b/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java new file mode 100644 index 0000000..1b77bab --- /dev/null +++ b/ext/src/test/java/zeroecho/ext/integrations/covert/jpeg/JpegExifEmbedderLengthTest.java @@ -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 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 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 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 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 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); + } + +} diff --git a/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java b/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java new file mode 100644 index 0000000..78612d3 --- /dev/null +++ b/ext/src/test/java/zeroecho/ext/integrations/stegano/LSBSteganographyMethodLengthTest.java @@ -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; + } + } +}