Android JNI: reject invalid length at direct buffer entry points#3178
Conversation
The public Android JNI decoder entry points accept a signed int length and forward it, unchecked, into size_t boundaries. A negative length (e.g. -1) sign-extends to a near-SIZE_MAX value, so the parser trusts the inflated advertised size and reads past the real direct-buffer allocation. See issue AOMediaCodec#3177 for the ASan heap-buffer-overflow reproducer. Add a ValidateDirectBuffer() helper that rejects negative lengths, non-direct buffers, lengths larger than the direct buffer capacity, and null addresses before any cast to size_t. Apply it at every public JNI entry point consuming (ByteBuffer, int) — isAvifImage, getInfo, decode, and createDecoder. Change the internal CreateDecoderAndParse() helper to take size_t length so the signed-to-unsigned sign-extension is gone from the internal path too. Add an Android instrumentation regression test (AvifDecoderLengthValidationTest) that covers length = -1, length = Integer.MIN_VALUE, length = capacity + 1, length = Integer.MAX_VALUE, heap-backed buffers, and the happy path on a valid image, verifying every case returns the clean-failure contract (false / null) instead of crashing. Leaves src/read.c, src/stream.c, and the Java public API surface (AvifDecoder.java) unchanged: the fix enforces the contract at the JNI boundary where the signed length enters. Fixes AOMediaCodec#3177 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
parasol-aser: Thank you for the bug report and the pull request. A weakness of the pull request is that it compares the Vignesh: I found that all the callers of the native methods that take Ideally we should remove the
|
| LOGE("AVIF JNI: negative length (%d) rejected.", length); | ||
| return false; | ||
| } | ||
| const jlong capacity = env->GetDirectBufferCapacity(encoded); |
There was a problem hiding this comment.
According to JNI documentation, GetDirectBufferCapacity() returns -1 if if the given object is not a direct java.nio.Buffer. So the new capacity < 0 check below is correct unless we know encoded must be a direct buffer.
| length, static_cast<long long>(capacity)); | ||
| return false; | ||
| } | ||
| const void* const address = env->GetDirectBufferAddress(encoded); |
There was a problem hiding this comment.
From the JNI documentation, it is less clear whether the address == nullptr check below is needed, given that GetDirectBufferCapacity() succeeded. But we can keep this check.
Summary
Fixes #3177.
The public Android JNI decoder wrapper (
android_jni/avifandroidjni/src/main/jni/libavif_jni.cc) accepts a caller-supplied signedint lengthat every direct-ByteBufferentry point and forwards it, unchecked, into the nativesize_t sizeboundary ofavifDecoderSetIOMemory(). A negative length (e.g.-1) or a negative-as-int oversize (e.g.0x80000000) sign-extends to a near-SIZE_MAXvalue; the parser then trusts the inflated advertised size and reads past the real direct-buffer allocation. ASan reports aheap-buffer-overflowinavifROStreamRead(see the stack in the issue).This PR enforces the contract at the JNI boundary — where the signed
intenters — so that every downstream caller ofavifDecoderSetIOMemory()stays free to pass anysize_tit likes.Changes
ValidateDirectBuffer()helper in thelibavif_jni.ccanonymous namespace that:length < 0before any cast.GetDirectBufferCapacity(encoded)once and rejects non-direct buffers (capacity < 0) andlength > capacity(strict>).nullptraddress fromGetDirectBufferAddress().size_tonly after the checks pass; returns the validated(buffer, size_t size)pair.ByteBufferand an explicit length:isAvifImage,getInfo,decode, andcreateDecoder. Each now early-exits with its clean-failure value (false/0) when validation fails.CreateDecoderAndParse()signature changed fromint lengthtosize_t lengthso the signed-to-unsigned sign-extension is gone from the internal helper too. All four call sites updated.AvifDecoderLengthValidationTestcovering:length = -1,length = Integer.MIN_VALUE).length = capacity + 1andlength = Integer.MAX_VALUE(positive-but-oversized).length == capacity,length == 0, and a valid image throughAvifDecoder.create()/AvifDecoder.getInfo().false/null) instead of a crash.Scope and non-goals
src/read.c,src/stream.c— unchanged. The crash manifests there, but the parser is trusting an already-bad size handed to it. Fixing it lower down would silently paper over other callers ofavifDecoderSetIOMemory()that are free to pass a largesize_tdeliberately.AvifDecoder.javapublic API surface — unchanged. The issue's secondary suggestion of deprecating theint lengthoverloads in favor ofencoded.remaining()-driven variants is deliberately not taken here; it would be a source-incompatible change, and the issue frames it as secondary hardening. Happy to do that as a follow-up if maintainers want it.Test plan
b357f08(currentmain), the 8-byte truncated-ftyppayload withlength = -1orlength = 0x80000000produces the ASanheap-buffer-overflowatavifROStreamRead→avifParseFileTypeBox→avifParse→avifDecoderParse(matches the issue).ValidateDirectBuffer()in place, the same inputs throughAvifDecoder.getInfo/decode/isAvifImage/createreturn the clean-failure value (false/null) without entering the parser. Verified with a simulated-JNI-validation harness that mirrors the new helper before callingavifDecoderSetIOMemory().avifDecoderSetIOMemory()directly with(size_t)-1still crashes as before — expected; the fix is at the JNI boundary, not in the parser. This is intentionally not a behavior change for non-JNI callers.AvifDecoderLengthValidationTeston an Android emulator/device, e.g.:avif/fox.profile0.8bpc.yuv420.avifasset already shipped underandroidTest/assetsfor the happy-path cases../android_jni/gradlew :avifandroidjni:assembleDebugmust continue to link cleanly after theCreateDecoderAndParsesignature change.-Wsign-compare/-Wconversionremain clean across the edited block (the helper uses an explicitstatic_cast<jlong>(length)for the capacity comparison).Boundary behavior verified by the test matrix
lengthgetInfo-1false, no crashgetInfoInteger.MIN_VALUEfalse, no crashgetInfocapacity + 1(9)false, no crashgetInfoInteger.MAX_VALUEfalse, no crashgetInfocapacity(8, truncated ftyp)false(parser — truncated data control)getInfo0(empty direct buffer)false(parser — not short-circuited at JNI)getInfotrue(happy path guard)decode-1/MIN_VALUE/cap+1/MAX_VALUEfalse, no crashisAvifImagefalse, no crashcreatenullcreatenull(happy path guard)🤖 Generated with Claude Code