diff --git a/android_jni/avifandroidjni/src/androidTest/java/org/aomedia/avif/android/AvifDecoderLengthValidationTest.java b/android_jni/avifandroidjni/src/androidTest/java/org/aomedia/avif/android/AvifDecoderLengthValidationTest.java new file mode 100644 index 0000000000..38f623b1d3 --- /dev/null +++ b/android_jni/avifandroidjni/src/androidTest/java/org/aomedia/avif/android/AvifDecoderLengthValidationTest.java @@ -0,0 +1,330 @@ +// Copyright 2026 Google LLC +// +// Regression tests for libavif issue #3177 — signed-length OOB read at the +// Android JNI boundary. See PLAN.md §5 "Test strategy" for the full coverage +// matrix. Every case must complete without a native crash / ASan abort; the +// clean-failure contract is: +// getInfo(...) -> false +// decode(...) -> false +// isAvifImage(...) -> false +// create(...) -> null +// +// Happy-path cases in this file exist to guard against the new validation +// accidentally over-rejecting legitimate inputs (length == capacity, +// length == 0, valid images through create()). + +package org.aomedia.avif.android; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import android.content.res.AssetManager; +import android.graphics.Bitmap; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +@RunWith(AndroidJUnit4.class) +public class AvifDecoderLengthValidationTest { + + /** The 8-byte truncated `ftyp` payload from issue #3177's PoC. */ + private static ByteBuffer tinyFtypDirectBuffer() { + // Layout: 4-byte big-endian box size (16) + 4-byte box type 'ftyp'. + // The declared size (16) intentionally exceeds the real capacity (8). + ByteBuffer buf = ByteBuffer.allocateDirect(8); + buf.order(ByteOrder.BIG_ENDIAN); + buf.putInt(16); + buf.put((byte) 'f'); + buf.put((byte) 't'); + buf.put((byte) 'y'); + buf.put((byte) 'p'); + buf.flip(); + return buf; + } + + /** Same bytes as {@link #tinyFtypDirectBuffer()} but heap-backed. */ + private static ByteBuffer tinyFtypHeapBuffer() { + ByteBuffer buf = ByteBuffer.allocate(8); + buf.order(ByteOrder.BIG_ENDIAN); + buf.putInt(16); + buf.put((byte) 'f'); + buf.put((byte) 't'); + buf.put((byte) 'y'); + buf.put((byte) 'p'); + buf.flip(); + return buf; + } + + private static ByteBuffer emptyDirectBuffer() { + ByteBuffer buf = ByteBuffer.allocateDirect(0); + buf.flip(); + return buf; + } + + private static ByteBuffer loadDirectAssetBuffer(String assetPath) throws IOException { + AssetManager assets = + InstrumentationRegistry.getInstrumentation().getContext().getAssets(); + try (InputStream in = assets.open(assetPath)) { + byte[] bytes = new byte[in.available()]; + int offset = 0; + while (offset < bytes.length) { + int read = in.read(bytes, offset, bytes.length - offset); + if (read < 0) { + break; + } + offset += read; + } + ByteBuffer buf = ByteBuffer.allocateDirect(offset); + buf.put(bytes, 0, offset); + buf.flip(); + return buf; + } + } + + private static Bitmap freshBitmap() { + // Dimensions are irrelevant — decode() must early-exit on validation failure + // before it touches the bitmap. Use a minimal 1x1 ARGB_8888 to keep memory + // pressure negligible for the happy-path cases too. + return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888); + } + + // --------------------------------------------------------------------------- + // getInfo + // --------------------------------------------------------------------------- + + @Test + public void getInfo_negativeOne_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, -1, info)); + } + + @Test + public void getInfo_integerMinValue_returnsFalseNoCrash() { + // 0x80000000 — the second value explicitly named in the issue PoC. + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, Integer.MIN_VALUE, info)); + } + + @Test + public void getInfo_largeNegativeOtherThanMinValue_returnsFalseNoCrash() { + // -2 exercises the generic `length < 0` branch (not just the PoC corners). + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, -2, info)); + } + + @Test + public void getInfo_lengthOneOverCapacity_returnsFalseNoCrash() { + // capacity == 8, length == 9. Strict `>` bound — must reject. + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, 9, info)); + } + + @Test + public void getInfo_integerMaxLengthOverCapacity_returnsFalseNoCrash() { + // Large positive length that still fits in a signed int. capacity == 8. + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, Integer.MAX_VALUE, info)); + } + + @Test + public void getInfo_honestLength_returnsFalseTruncatedControl() { + // PLAN.md §5 layer-2 case #4: the pre-fix behavior on honest length must be + // preserved (truncated ftyp -> parser-level error, clean `false`). + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, 8, info)); + } + + @Test + public void getInfo_lengthEqualsCapacity_notRejectedByBoundary() { + // PLAN.md §4: the check is strict `>`, so length == capacity (8) must not be + // rejected by the helper. Parser still returns false on truncated data, but + // reaching the parser is the whole point of this case. + ByteBuffer buf = tinyFtypDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, buf.capacity(), info)); + } + + @Test + public void getInfo_lengthZero_passedThroughToParser() { + // PLAN.md §4: length == 0 must *not* be short-circuited at the JNI layer. + // The parser handles the zero-byte case itself and returns a clean error; + // we verify we still get a clean false-without-crash. + ByteBuffer buf = emptyDirectBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, 0, info)); + } + + @Test + public void getInfo_heapBackedBuffer_returnsFalseNoCrash() { + // Non-direct ByteBuffer -> GetDirectBufferCapacity returns -1 -> reject. + // Also guards the CR-7 null-address bug noted in PLAN.md §4. + ByteBuffer buf = tinyFtypHeapBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, buf.remaining(), info)); + } + + @Test + public void getInfo_heapBackedBufferNegativeLength_returnsFalseNoCrash() { + // Crossed-cases: both the negative-length and the not-a-direct-buffer branch + // apply. Whichever rejects first is fine — the contract is a clean false. + ByteBuffer buf = tinyFtypHeapBuffer(); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, -1, info)); + } + + // --------------------------------------------------------------------------- + // decode + // --------------------------------------------------------------------------- + + @Test + public void decode_negativeOne_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, -1, bmp, 1)); + } + + @Test + public void decode_integerMinValue_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, Integer.MIN_VALUE, bmp, 1)); + } + + @Test + public void decode_lengthOneOverCapacity_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, 9, bmp, 1)); + } + + @Test + public void decode_integerMaxLengthOverCapacity_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, Integer.MAX_VALUE, bmp, 1)); + } + + @Test + public void decode_honestLength_returnsFalseTruncatedControl() { + ByteBuffer buf = tinyFtypDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, 8, bmp, 1)); + } + + @Test + public void decode_heapBackedBuffer_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypHeapBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, buf.remaining(), bmp, 1)); + } + + @Test + public void decode_lengthZero_passedThroughToParser() { + ByteBuffer buf = emptyDirectBuffer(); + Bitmap bmp = freshBitmap(); + assertFalse(AvifDecoder.decode(buf, 0, bmp, 1)); + } + + // --------------------------------------------------------------------------- + // isAvifImage — public wrapper uses `encoded.remaining()` internally, so the + // length branch is exercised by way of buffer-shape variations. + // --------------------------------------------------------------------------- + + @Test + public void isAvifImage_truncatedFtypDirect_returnsFalseNoCrash() { + ByteBuffer buf = tinyFtypDirectBuffer(); + assertFalse(AvifDecoder.isAvifImage(buf)); + } + + @Test + public void isAvifImage_heapBackedBuffer_returnsFalseNoCrash() { + // GetDirectBufferCapacity(...) == -1 -> reject path. + ByteBuffer buf = tinyFtypHeapBuffer(); + assertFalse(AvifDecoder.isAvifImage(buf)); + } + + @Test + public void isAvifImage_emptyDirectBuffer_returnsFalseNoCrash() { + ByteBuffer buf = emptyDirectBuffer(); + assertFalse(AvifDecoder.isAvifImage(buf)); + } + + // --------------------------------------------------------------------------- + // create (AvifDecoder factory). Uses encoded.remaining() internally, so the + // length branch can't be poisoned from the public Java surface. These tests + // guard against the createDecoder hardening accidentally breaking the happy + // path or the clean-failure contract on malformed input. + // --------------------------------------------------------------------------- + + @Test + public void create_truncatedFtypDirect_returnsNull() { + // The tiny_ftyp 8-byte payload is not a valid AVIF — create() must return + // null without crashing. + ByteBuffer buf = tinyFtypDirectBuffer(); + assertNull(AvifDecoder.create(buf)); + } + + @Test + public void create_heapBackedBuffer_returnsNull() { + // Non-direct buffer: GetDirectBufferCapacity returns -1 -> clean failure. + ByteBuffer buf = tinyFtypHeapBuffer(); + assertNull(AvifDecoder.create(buf)); + } + + @Test + public void create_emptyDirectBuffer_returnsNull() { + ByteBuffer buf = emptyDirectBuffer(); + assertNull(AvifDecoder.create(buf)); + } + + @Test + public void create_validImage_stillReturnsNonNull() throws IOException { + // PLAN.md §5 layer-2 case #8: guards against the private createDecoder + // hardening accidentally rejecting the happy path. + ByteBuffer buf = loadDirectAssetBuffer("avif/fox.profile0.8bpc.yuv420.avif"); + AvifDecoder decoder = AvifDecoder.create(buf); + assertNotNull(decoder); + } + + @Test + public void getInfo_validImageHonestLength_returnsTrue() throws IOException { + // Sanity: after hardening, the honest-length path on a well-formed image + // must continue to succeed. Regression guard on the `length == capacity` + // happy path at a realistic size (not just 8 bytes). + ByteBuffer buf = loadDirectAssetBuffer("avif/fox.profile0.8bpc.yuv420.avif"); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertTrue(AvifDecoder.getInfo(buf, buf.remaining(), info)); + } + + @Test + public void getInfo_validImageNegativeLength_returnsFalseNoCrash() throws IOException { + // A large, legitimately-sized direct buffer plus a negative length must + // still be rejected — the sign-extension path is the original CVE. + ByteBuffer buf = loadDirectAssetBuffer("avif/fox.profile0.8bpc.yuv420.avif"); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, -1, info)); + } + + @Test + public void getInfo_validImageLengthOneOverCapacity_returnsFalseNoCrash() throws IOException { + ByteBuffer buf = loadDirectAssetBuffer("avif/fox.profile0.8bpc.yuv420.avif"); + AvifDecoder.Info info = new AvifDecoder.Info(); + assertFalse(AvifDecoder.getInfo(buf, buf.capacity() + 1, info)); + } +} diff --git a/android_jni/avifandroidjni/src/main/jni/libavif_jni.cc b/android_jni/avifandroidjni/src/main/jni/libavif_jni.cc index 83eb3e3d93..cec1c880e5 100644 --- a/android_jni/avifandroidjni/src/main/jni/libavif_jni.cc +++ b/android_jni/avifandroidjni/src/main/jni/libavif_jni.cc @@ -50,8 +50,39 @@ struct AvifDecoderWrapper { avifCropRect crop; }; +// Returns true when `encoded` is a direct ByteBuffer of at least `length` +// bytes and `length` is non-negative. On success, `*out_buffer` is set to the +// direct buffer address and `*out_size` is set to `(size_t)length`. On +// failure, logs via LOGE() and returns false; callers should propagate a +// clean failure (false / 0 / nullptr) to the Java layer. +bool ValidateDirectBuffer(JNIEnv* env, jobject encoded, jint length, + const uint8_t** out_buffer, size_t* out_size) { + if (length < 0) { + LOGE("AVIF JNI: negative length (%d) rejected.", length); + return false; + } + const jlong capacity = env->GetDirectBufferCapacity(encoded); + if (capacity < 0) { + LOGE("AVIF JNI: encoded is not a direct ByteBuffer."); + return false; + } + if (static_cast(length) > capacity) { + LOGE("AVIF JNI: length (%d) exceeds direct buffer capacity (%lld).", + length, static_cast(capacity)); + return false; + } + const void* const address = env->GetDirectBufferAddress(encoded); + if (address == nullptr) { + LOGE("AVIF JNI: GetDirectBufferAddress returned null."); + return false; + } + *out_buffer = static_cast(address); + *out_size = static_cast(length); + return true; +} + bool CreateDecoderAndParse(AvifDecoderWrapper* const decoder, - const uint8_t* const buffer, int length, + const uint8_t* const buffer, size_t length, int threads) { decoder->decoder = avifDecoderCreate(); if (decoder->decoder == nullptr) { @@ -255,9 +286,12 @@ jint JNI_OnLoad(JavaVM* vm, void* /*reserved*/) { FUNC(jboolean, isAvifImage, jobject encoded, int length) { IGNORE_UNUSED_JNI_PARAMETERS; - const uint8_t* const buffer = - static_cast(env->GetDirectBufferAddress(encoded)); - const avifROData avif = {buffer, static_cast(length)}; + const uint8_t* buffer = nullptr; + size_t size = 0; + if (!ValidateDirectBuffer(env, encoded, length, &buffer, &size)) { + return false; + } + const avifROData avif = {buffer, size}; return avifPeekCompatibleFileType(&avif); } @@ -278,10 +312,13 @@ FUNC(jboolean, isAvifImage, jobject encoded, int length) { FUNC(jboolean, getInfo, jobject encoded, int length, jobject info) { IGNORE_UNUSED_JNI_PARAMETERS; - const uint8_t* const buffer = - static_cast(env->GetDirectBufferAddress(encoded)); + const uint8_t* buffer = nullptr; + size_t size = 0; + if (!ValidateDirectBuffer(env, encoded, length, &buffer, &size)) { + return false; + } AvifDecoderWrapper decoder; - if (!CreateDecoderAndParse(&decoder, buffer, length, /*threads=*/1)) { + if (!CreateDecoderAndParse(&decoder, buffer, size, /*threads=*/1)) { return false; } FIND_CLASS(info_class, "org/aomedia/avif/android/AvifDecoder$Info", false); @@ -303,10 +340,13 @@ FUNC(jboolean, getInfo, jobject encoded, int length, jobject info) { FUNC(jboolean, decode, jobject encoded, int length, jobject bitmap, jint threads) { IGNORE_UNUSED_JNI_PARAMETERS; - const uint8_t* const buffer = - static_cast(env->GetDirectBufferAddress(encoded)); + const uint8_t* buffer = nullptr; + size_t size = 0; + if (!ValidateDirectBuffer(env, encoded, length, &buffer, &size)) { + return false; + } AvifDecoderWrapper decoder; - if (!CreateDecoderAndParse(&decoder, buffer, length, + if (!CreateDecoderAndParse(&decoder, buffer, size, getThreadCount(threads))) { return false; } @@ -314,14 +354,17 @@ FUNC(jboolean, decode, jobject encoded, int length, jobject bitmap, } FUNC(jlong, createDecoder, jobject encoded, jint length, jint threads) { - const uint8_t* const buffer = - static_cast(env->GetDirectBufferAddress(encoded)); + const uint8_t* buffer = nullptr; + size_t size = 0; + if (!ValidateDirectBuffer(env, encoded, length, &buffer, &size)) { + return 0; + } std::unique_ptr decoder(new (std::nothrow) AvifDecoderWrapper()); if (decoder == nullptr) { return 0; } - if (!CreateDecoderAndParse(decoder.get(), buffer, length, + if (!CreateDecoderAndParse(decoder.get(), buffer, size, getThreadCount(threads))) { return 0; }