From 1d8855b47d52b33e1f405844e6e9f614bc7f3657 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Mon, 3 Oct 2022 23:23:12 +0200 Subject: [PATCH] skip reading large PNG chunks --- CHANGELOG.md | 1 + .../aves/metadata/metadataextractor/Helper.kt | 5 + .../SafePngMetadataReader.kt | 302 ++++++++++++++++++ .../metadataextractor/SafeXmpReader.kt | 2 +- 4 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c3d022906..a2b3df8ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file. ### Fixed - restoring to missing Download subdir +- crash when cataloguing PNG with large chunks ## [v1.7.0] - 2022-09-19 diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt index 06add40ae..8f6672ce9 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/Helper.kt @@ -67,6 +67,7 @@ object Helper { val metadata = when (fileType) { FileType.Jpeg -> safeReadJpeg(inputStream) + FileType.Png -> safeReadPng(inputStream) FileType.Tiff, FileType.Arw, FileType.Cr2, @@ -95,6 +96,10 @@ object Helper { return metadata } + private fun safeReadPng(input: InputStream): com.drew.metadata.Metadata { + return SafePngMetadataReader.readMetadata(input) + } + @Throws(IOException::class, TiffProcessingException::class) fun safeReadTiff(input: InputStream): com.drew.metadata.Metadata { val reader = RandomAccessStreamReader(input, RandomAccessStreamReader.DEFAULT_CHUNK_LENGTH, safeReadStreamLength) diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt new file mode 100644 index 000000000..b0baa7973 --- /dev/null +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafePngMetadataReader.kt @@ -0,0 +1,302 @@ +package deckers.thibault.aves.metadata.metadataextractor + +import android.util.Log +import com.drew.imaging.png.* +import com.drew.imaging.tiff.TiffProcessingException +import com.drew.imaging.tiff.TiffReader +import com.drew.lang.* +import com.drew.lang.annotations.NotNull +import com.drew.metadata.ErrorDirectory +import com.drew.metadata.Metadata +import com.drew.metadata.StringValue +import com.drew.metadata.exif.ExifTiffHandler +import com.drew.metadata.icc.IccReader +import com.drew.metadata.png.PngChromaticitiesDirectory +import com.drew.metadata.png.PngDirectory +import com.drew.metadata.xmp.XmpReader +import deckers.thibault.aves.utils.LogUtils +import java.io.ByteArrayInputStream +import java.io.IOException +import java.io.InputStream +import java.util.zip.InflaterInputStream +import java.util.zip.ZipException + +// adapted from `PngMetadataReader` to prevent reading OOM from large chunks +// as of `metadata-extractor` v2.18.0, there is no way to customize the reader +// without copying `desiredChunkTypes` and the whole `processChunk` function +object SafePngMetadataReader { + private val LOG_TAG = LogUtils.createTag() + + // arbitrary size to detect chunks that may yield an OOM + private const val chunkSizeDangerThreshold = SafeXmpReader.segmentTypeSizeDangerThreshold + + private val latin1Encoding = Charsets.ISO_8859_1 + private val desiredChunkTypes: Set = hashSetOf( + PngChunkType.IHDR, + PngChunkType.PLTE, + PngChunkType.tRNS, + PngChunkType.cHRM, + PngChunkType.sRGB, + PngChunkType.gAMA, + PngChunkType.iCCP, + PngChunkType.bKGD, + PngChunkType.tEXt, + PngChunkType.zTXt, + PngChunkType.iTXt, + PngChunkType.tIME, + PngChunkType.pHYs, + PngChunkType.sBIT, + PngChunkType.eXIf, + ) + + @Throws(IOException::class, PngProcessingException::class) + fun readMetadata(inputStream: InputStream): Metadata { + val chunks = PngChunkReader().extract(StreamReader(inputStream), desiredChunkTypes) + val metadata = Metadata() + for (chunk in chunks) { + try { + processChunk(metadata, chunk) + } catch (e: Exception) { + metadata.addDirectory(ErrorDirectory("Exception reading PNG chunk: " + e.message)) + } + } + return metadata + } + + @Throws(PngProcessingException::class, IOException::class) + private fun processChunk(@NotNull metadata: Metadata, @NotNull chunk: PngChunk) { + val chunkType = chunk.type + val bytes = chunk.bytes + + // TLAD insert start + if (bytes.size > chunkSizeDangerThreshold) { + Log.w(LOG_TAG, "PNG chunk $chunkType is too large, with a size of ${bytes.size} B") + return + } + // TLAD insert end + + if (chunkType == PngChunkType.IHDR) { + val header = PngHeader(bytes) + val directory = PngDirectory(PngChunkType.IHDR) + directory.setInt(PngDirectory.TAG_IMAGE_WIDTH, header.imageWidth) + directory.setInt(PngDirectory.TAG_IMAGE_HEIGHT, header.imageHeight) + directory.setInt(PngDirectory.TAG_BITS_PER_SAMPLE, header.bitsPerSample.toInt()) + directory.setInt(PngDirectory.TAG_COLOR_TYPE, header.colorType.numericValue) + directory.setInt(PngDirectory.TAG_COMPRESSION_TYPE, header.compressionType.toInt() and 0xFF) // make sure it's unsigned + directory.setInt(PngDirectory.TAG_FILTER_METHOD, header.filterMethod.toInt()) + directory.setInt(PngDirectory.TAG_INTERLACE_METHOD, header.interlaceMethod.toInt()) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.PLTE) { + val directory = PngDirectory(PngChunkType.PLTE) + directory.setInt(PngDirectory.TAG_PALETTE_SIZE, bytes.size / 3) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.tRNS) { + val directory = PngDirectory(PngChunkType.tRNS) + directory.setInt(PngDirectory.TAG_PALETTE_HAS_TRANSPARENCY, 1) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.sRGB) { + val srgbRenderingIntent = bytes[0].toInt() + val directory = PngDirectory(PngChunkType.sRGB) + directory.setInt(PngDirectory.TAG_SRGB_RENDERING_INTENT, srgbRenderingIntent) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.cHRM) { + val chromaticities = PngChromaticities(bytes) + val directory = PngChromaticitiesDirectory() + directory.setInt(PngChromaticitiesDirectory.TAG_WHITE_POINT_X, chromaticities.whitePointX) + directory.setInt(PngChromaticitiesDirectory.TAG_WHITE_POINT_Y, chromaticities.whitePointY) + directory.setInt(PngChromaticitiesDirectory.TAG_RED_X, chromaticities.redX) + directory.setInt(PngChromaticitiesDirectory.TAG_RED_Y, chromaticities.redY) + directory.setInt(PngChromaticitiesDirectory.TAG_GREEN_X, chromaticities.greenX) + directory.setInt(PngChromaticitiesDirectory.TAG_GREEN_Y, chromaticities.greenY) + directory.setInt(PngChromaticitiesDirectory.TAG_BLUE_X, chromaticities.blueX) + directory.setInt(PngChromaticitiesDirectory.TAG_BLUE_Y, chromaticities.blueY) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.gAMA) { + val gammaInt = ByteConvert.toInt32BigEndian(bytes) + SequentialByteArrayReader(bytes).int32 + val directory = PngDirectory(PngChunkType.gAMA) + directory.setDouble(PngDirectory.TAG_GAMMA, gammaInt / 100000.0) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.iCCP) { + val reader: SequentialReader = SequentialByteArrayReader(bytes) + + // Profile Name is 1-79 bytes, followed by the 1 byte null character + val profileNameBytes = reader.getNullTerminatedBytes(79 + 1) + val directory = PngDirectory(PngChunkType.iCCP) + directory.setStringValue(PngDirectory.TAG_ICC_PROFILE_NAME, StringValue(profileNameBytes, latin1Encoding)) + val compressionMethod = reader.int8 + // Only compression method allowed by the spec is zero: deflate + if (compressionMethod.toInt() == 0) { + // bytes left for compressed text is: + // total bytes length - (profilenamebytes length + null byte + compression method byte) + val bytesLeft = bytes.size - (profileNameBytes.size + 1 + 1) + val compressedProfile = reader.getBytes(bytesLeft) + try { + val inflateStream = InflaterInputStream(ByteArrayInputStream(compressedProfile)) + IccReader().extract(RandomAccessStreamReader(inflateStream), metadata, directory) + inflateStream.close() + } catch (zex: ZipException) { + directory.addError(String.format("Exception decompressing PNG iCCP chunk : %s", zex.message)) + metadata.addDirectory(directory) + } + } else { + directory.addError("Invalid compression method value") + } + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.bKGD) { + val directory = PngDirectory(PngChunkType.bKGD) + directory.setByteArray(PngDirectory.TAG_BACKGROUND_COLOR, bytes) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.tEXt) { + val reader: SequentialReader = SequentialByteArrayReader(bytes) + + // Keyword is 1-79 bytes, followed by the 1 byte null character + val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding) + val keyword = keywordsv.toString() + + // bytes left for text is: + // total bytes length - (Keyword length + null byte) + val bytesLeft = bytes.size - (keywordsv.bytes.size + 1) + val value = reader.getNullTerminatedStringValue(bytesLeft, latin1Encoding) + val textPairs: MutableList = ArrayList() + textPairs.add(KeyValuePair(keyword, value)) + val directory = PngDirectory(PngChunkType.tEXt) + directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.zTXt) { + val reader: SequentialReader = SequentialByteArrayReader(bytes) + + // Keyword is 1-79 bytes, followed by the 1 byte null character + val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding) + val keyword = keywordsv.toString() + val compressionMethod = reader.int8 + + // bytes left for compressed text is: + // total bytes length - (Keyword length + null byte + compression method byte) + val bytesLeft = bytes.size - (keywordsv.bytes.size + 1 + 1) + var textBytes: ByteArray? = null + if (compressionMethod.toInt() == 0) { + try { + textBytes = StreamUtil.readAllBytes(InflaterInputStream(ByteArrayInputStream(bytes, bytes.size - bytesLeft, bytesLeft))) + } catch (zex: ZipException) { + val directory = PngDirectory(PngChunkType.zTXt) + directory.addError(String.format("Exception decompressing PNG zTXt chunk with keyword \"%s\": %s", keyword, zex.message)) + metadata.addDirectory(directory) + } + } else { + val directory = PngDirectory(PngChunkType.zTXt) + directory.addError("Invalid compression method value") + metadata.addDirectory(directory) + } + if (textBytes != null) { + if (keyword == "XML:com.adobe.xmp") { + // NOTE in testing images, the XMP has parsed successfully, but we are not extracting tags from it as necessary + XmpReader().extract(textBytes, metadata) + } else { + val textPairs: MutableList = ArrayList() + textPairs.add(KeyValuePair(keyword, StringValue(textBytes, latin1Encoding))) + val directory = PngDirectory(PngChunkType.zTXt) + directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs) + metadata.addDirectory(directory) + } + } + } else if (chunkType == PngChunkType.iTXt) { + val reader: SequentialReader = SequentialByteArrayReader(bytes) + + // Keyword is 1-79 bytes, followed by the 1 byte null character + val keywordsv = reader.getNullTerminatedStringValue(79 + 1, latin1Encoding) + val keyword = keywordsv.toString() + val compressionFlag = reader.int8 + val compressionMethod = reader.int8 + // TODO we currently ignore languageTagBytes and translatedKeywordBytes + val languageTagBytes = reader.getNullTerminatedBytes(bytes.size) + val translatedKeywordBytes = reader.getNullTerminatedBytes(bytes.size) + + // bytes left for compressed text is: + // total bytes length - (Keyword length + null byte + comp flag byte + comp method byte + lang length + null byte + translated length + null byte) + val bytesLeft = bytes.size - (keywordsv.bytes.size + 1 + 1 + 1 + languageTagBytes.size + 1 + translatedKeywordBytes.size + 1) + var textBytes: ByteArray? = null + if (compressionFlag.toInt() == 0) { + textBytes = reader.getNullTerminatedBytes(bytesLeft) + } else if (compressionFlag.toInt() == 1) { + if (compressionMethod.toInt() == 0) { + try { + textBytes = StreamUtil.readAllBytes(InflaterInputStream(ByteArrayInputStream(bytes, bytes.size - bytesLeft, bytesLeft))) + } catch (zex: ZipException) { + val directory = PngDirectory(PngChunkType.iTXt) + directory.addError(String.format("Exception decompressing PNG iTXt chunk with keyword \"%s\": %s", keyword, zex.message)) + metadata.addDirectory(directory) + } + } else { + val directory = PngDirectory(PngChunkType.iTXt) + directory.addError("Invalid compression method value") + metadata.addDirectory(directory) + } + } else { + val directory = PngDirectory(PngChunkType.iTXt) + directory.addError("Invalid compression flag value") + metadata.addDirectory(directory) + } + if (textBytes != null) { + if (keyword == "XML:com.adobe.xmp") { + // NOTE in testing images, the XMP has parsed successfully, but we are not extracting tags from it as necessary + XmpReader().extract(textBytes, metadata) + } else { + val textPairs: MutableList = ArrayList() + textPairs.add(KeyValuePair(keyword, StringValue(textBytes, latin1Encoding))) + val directory = PngDirectory(PngChunkType.iTXt) + directory.setObject(PngDirectory.TAG_TEXTUAL_DATA, textPairs) + metadata.addDirectory(directory) + } + } + } else if (chunkType == PngChunkType.tIME) { + val reader = SequentialByteArrayReader(bytes) + val year = reader.uInt16 + val month = reader.uInt8.toInt() + val day = reader.uInt8.toInt() + val hour = reader.uInt8.toInt() + val minute = reader.uInt8.toInt() + val second = reader.uInt8.toInt() + val directory = PngDirectory(PngChunkType.tIME) + if (DateUtil.isValidDate(year, month - 1, day) && DateUtil.isValidTime(hour, minute, second)) { + val dateString = String.format("%04d:%02d:%02d %02d:%02d:%02d", year, month, day, hour, minute, second) + directory.setString(PngDirectory.TAG_LAST_MODIFICATION_TIME, dateString) + } else { + directory.addError( + String.format( + "PNG tIME data describes an invalid date/time: year=%d month=%d day=%d hour=%d minute=%d second=%d", + year, month, day, hour, minute, second + ) + ) + } + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.pHYs) { + val reader = SequentialByteArrayReader(bytes) + val pixelsPerUnitX = reader.int32 + val pixelsPerUnitY = reader.int32 + val unitSpecifier = reader.int8 + val directory = PngDirectory(PngChunkType.pHYs) + directory.setInt(PngDirectory.TAG_PIXELS_PER_UNIT_X, pixelsPerUnitX) + directory.setInt(PngDirectory.TAG_PIXELS_PER_UNIT_Y, pixelsPerUnitY) + directory.setInt(PngDirectory.TAG_UNIT_SPECIFIER, unitSpecifier.toInt()) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.sBIT) { + val directory = PngDirectory(PngChunkType.sBIT) + directory.setByteArray(PngDirectory.TAG_SIGNIFICANT_BITS, bytes) + metadata.addDirectory(directory) + } else if (chunkType == PngChunkType.eXIf) { + try { + val handler = ExifTiffHandler(metadata, null) + TiffReader().processTiff(ByteArrayReader(bytes), handler, 0) + } catch (ex: TiffProcessingException) { + val directory = PngDirectory(PngChunkType.eXIf) + directory.addError(ex.message) + metadata.addDirectory(directory) + } catch (ex: IOException) { + val directory = PngDirectory(PngChunkType.eXIf) + directory.addError(ex.message) + metadata.addDirectory(directory) + } + } + } +} \ No newline at end of file diff --git a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt index c70c09ae2..db58da6d6 100644 --- a/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt +++ b/android/app/src/main/kotlin/deckers/thibault/aves/metadata/metadataextractor/SafeXmpReader.kt @@ -135,7 +135,7 @@ class SafeXmpReader : XmpReader() { private val LOG_TAG = LogUtils.createTag() // arbitrary size to detect extended XMP that may yield an OOM - private const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB + const val segmentTypeSizeDangerThreshold = 3 * (1 shl 20) // MB // tighter node limits for faster loading val PARSE_OPTIONS: ParseOptions = ParseOptions().setXMPNodesToLimit(