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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
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.");
|
||||
}
|
||||
|
||||
if (messageLength != -1 && messageBytes.size() == messageLength) {
|
||||
break outer;
|
||||
}
|
||||
}
|
||||
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.toByteArray());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user