Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ class KmzParser(
@Throws(IOException::class)
private fun checkLimit() {
if (mTotalBytes > mMaxBytes) {
throw java.io.IOException("Zip bomb detected! Uncompressed size exceeds limit of $mMaxBytes bytes.")
throw IOException("Zip bomb detected! Uncompressed size exceeds limit of $mMaxBytes bytes.")
}
}
}
Expand All @@ -114,24 +114,31 @@ class KmzParser(
while (entry != null) {
entryCount++
if (entryCount > maxKmzEntryCount) {
throw java.io.IOException("Zip bomb detected! Max number of entries exceeded: $maxKmzEntryCount")
throw IOException("Zip bomb detected! Max number of entries exceeded: $maxKmzEntryCount")
}
val name = entry.name
if (!entry.isDirectory) {
if (name.endsWith(".kml", ignoreCase = true) && kml == null) {
// Found the KML file (first one found is usually the main one in KMZ)
// We need to read it into a byte array because we can't close the ZipInputStream yet
val bytes = countingStream.readBytes()
kml = KmlParser().parse(ByteArrayInputStream(bytes))
} else {
// Try to decode as image
val bytes = countingStream.readBytes()
val bitmap = imageDecoder.decode(bytes)
if (bitmap != null) {
images[name] = bitmap
}
if (entry.isDirectory) {
// Directory entries in a ZIP archive must never carry a data payload.
// Reading even a single byte detects rogue payloads without decompressing large streams.
if (countingStream.read() != -1) {
throw IOException("Zip bomb or malformed KMZ detected: directory entry '$name' contains unexpected data payload.")
}
} else if (name.endsWith(".kml", ignoreCase = true) && kml == null) {
// Found the KML file (first one found is usually the main one in KMZ)
// We need to read it into a byte array because we can't close the ZipInputStream yet
val bytes = countingStream.readBytes()
kml = KmlParser().parse(ByteArrayInputStream(bytes))
} else {
// Try to decode as image
val bytes = countingStream.readBytes()
val bitmap = imageDecoder.decode(bytes)
if (bitmap != null) {
images[name] = bitmap
}
}
// Drain any unread bytes through countingStream before closing entry to ensure
// ZipInputStream.closeEntry() never bypasses cumulative byte accounting.
drainEntry(countingStream)
zipInputStream.closeEntry()
entry = zipInputStream.nextEntry
}
Expand All @@ -146,7 +153,20 @@ class KmzParser(
return kml.copy(images = images)
}

/**
* Drains any remaining bytes from the current zip entry through the counting stream,
* ensuring all inflated bytes count towards the uncompressed size limit.
*/
@Throws(IOException::class)
private fun drainEntry(stream: InputStream) {
val buffer = ByteArray(DRAIN_BUFFER_SIZE)
while (stream.read(buffer) != -1) {
// CountingInputStream counts bytes read and enforces maxKmzUncompressedTotalSize limit.
}
}

companion object {
private const val DRAIN_BUFFER_SIZE = 8192
val SUPPORTED_EXTENSIONS = setOf("kmz")

fun canParse(header: String): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.Assert.assertThrows
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.IOException
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

Expand Down Expand Up @@ -70,6 +72,78 @@ class KmzParserTest {
assertEquals(mockBitmap, kml.images["image.png"])
}

/**
* Demonstrates issue #1790: KMZ directory entries bypass the cumulative decompression limit.
*
* A malicious KMZ can package a large compressed payload inside an entry named with a trailing slash
* (e.g., "padding/"). Without proper accounting or validation, ZipInputStream.closeEntry() silently
* inflates and drains the entry without incrementing the uncompressed byte counter, bypassing
* the configured decompression budget.
*/
@Test
fun `parse rejects directory entry with payload exceeding decompression limit`() {
val kmlContent =
"""
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Placemark>
<name>Test Placemark</name>
</Placemark>
</Document>
</kml>
""".trimIndent()

// 2,000 bytes payload in a directory entry, configured with a 1,000-byte limit
val directoryPayload = ByteArray(2000) { 0x41 }
val kmzStream =
createKmzStream(
"padding/" to directoryPayload,
"doc.kml" to kmlContent.toByteArray(),
)

val parser = KmzParser(maxKmzUncompressedTotalSize = 1000L)

assertThrows(IOException::class.java) {
parser.parse(kmzStream)
}
}

/**
* Verifies that standard well-formed KMZ files containing empty directory entries
* (e.g. "images/") parse successfully without false-positive zip bomb rejections.
*/
@Test
fun `parse allows legitimate empty directory entries`() {
val kmlContent =
"""
<kml xmlns="http://www.opengis.net/kml/2.2">
<Document>
<Placemark>
<name>Doc in Dir</name>
</Placemark>
</Document>
</kml>
""".trimIndent()

val kmzStream =
createKmzStream(
"images/" to ByteArray(0),
"doc.kml" to kmlContent.toByteArray(),
)

val parser = KmzParser()
val kml = parser.parse(kmzStream)

assertNotNull(kml.document)
assertEquals(
"Doc in Dir",
kml.document
?.placemarks
?.first()
?.name,
)
}

private fun createKmzStream(vararg entries: kotlin.Pair<String, ByteArray>): ByteArrayInputStream {
val baos = ByteArrayOutputStream()
val zos = ZipOutputStream(baos)
Expand Down
Loading