diff --git a/sanitizers/sanitizers.bzl b/sanitizers/sanitizers.bzl index a71644312..9f32fba30 100644 --- a/sanitizers/sanitizers.bzl +++ b/sanitizers/sanitizers.bzl @@ -34,6 +34,7 @@ _sanitizer_class_names = [ "SqlInjection", "UnsafeSanitizer", "XPathInjection", + "XmlParserSsrfGuidance", ] SANITIZER_CLASSES = [_sanitizer_package_prefix + class_name for class_name in _sanitizer_class_names] diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel index 57726fa20..89fb76664 100644 --- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel @@ -81,6 +81,7 @@ kt_jvm_library( "RegexInjection.kt", "Utils.kt", "XPathInjection.kt", + "XmlParserSsrfGuidance.kt", ], visibility = [ "//sanitizers:__pkg__", diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt index 261b3cb43..d91d57bf4 100644 --- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt @@ -17,6 +17,7 @@ package com.code_intelligence.jazzer.sanitizers import com.code_intelligence.jazzer.api.HookType +import com.code_intelligence.jazzer.api.Jazzer import com.code_intelligence.jazzer.api.MethodHook import com.code_intelligence.jazzer.api.MethodHooks import java.io.BufferedInputStream @@ -101,7 +102,11 @@ object Deserialization { BufferedInputStream(originalInputStream) } args[0] = fixedInputStream - guideMarkableInputStreamTowardsEquality(fixedInputStream, OBJECT_INPUT_STREAM_HEADER, hookId) + Jazzer.guideTowardsEquality( + peekMarkableInputStream(fixedInputStream, OBJECT_INPUT_STREAM_HEADER.size), + OBJECT_INPUT_STREAM_HEADER, + hookId, + ) } /** @@ -157,6 +162,10 @@ object Deserialization { ) { val inputStream = inputStreamForObjectInputStream.get()[objectInputStream] if (inputStream?.markSupported() != true) return - guideMarkableInputStreamTowardsEquality(inputStream, SERIALIZED_JAZ_ZER_INSTANCE, hookId) + Jazzer.guideTowardsEquality( + peekMarkableInputStream(inputStream, SERIALIZED_JAZ_ZER_INSTANCE.size), + SERIALIZED_JAZ_ZER_INSTANCE, + hookId, + ) } } diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Utils.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Utils.kt index 592b3120c..9a6c97fc7 100644 --- a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Utils.kt +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Utils.kt @@ -16,8 +16,8 @@ package com.code_intelligence.jazzer.sanitizers -import com.code_intelligence.jazzer.api.Jazzer import java.io.InputStream +import java.io.Reader /** * jaz.Zer is a honeypot class: All of its methods report a finding when called. @@ -44,11 +44,10 @@ internal fun ByteArray.indexOf(needle: ByteArray): Int { return -1 } -internal fun guideMarkableInputStreamTowardsEquality( +internal fun peekMarkableInputStream( stream: InputStream, - target: ByteArray, - id: Int, -) { + readlimit: Int, +): ByteArray { fun readBytes( stream: InputStream, size: Int, @@ -60,12 +59,35 @@ internal fun guideMarkableInputStreamTowardsEquality( if (count < 0) break n += count } - return current + return if (n >= readlimit) current else current.copyOf(n) } - check(stream.markSupported()) - stream.mark(target.size) - val current = readBytes(stream, target.size) + stream.mark(readlimit) + val content = readBytes(stream, readlimit) stream.reset() - Jazzer.guideTowardsEquality(current, target, id) + return content +} + +internal fun peekMarkableReader( + reader: Reader, + readlimit: Int, +): String { + fun readString( + reader: Reader, + size: Int, + ): String { + val current = CharArray(size) + var n = 0 + while (n < size) { + val count = reader.read(current, n, size - n) + if (count < 0) break + n += count + } + return String(current, 0, n) + } + check(reader.markSupported()) + reader.mark(readlimit) + val content = readString(reader, readlimit) + reader.reset() + return content } diff --git a/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XmlParserSsrfGuidance.kt b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XmlParserSsrfGuidance.kt new file mode 100644 index 000000000..65607e26b --- /dev/null +++ b/sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/XmlParserSsrfGuidance.kt @@ -0,0 +1,184 @@ +/* + * Copyright 2025 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.code_intelligence.jazzer.sanitizers + +import com.code_intelligence.jazzer.api.HookType +import com.code_intelligence.jazzer.api.Jazzer +import com.code_intelligence.jazzer.api.MethodHook +import com.code_intelligence.jazzer.api.MethodHooks +import org.xml.sax.InputSource +import java.io.InputStream +import java.lang.invoke.MethodHandle + +/** + * Guides XML parser entry points towards patterns that can trigger external resource fetching. + * + * This does not report findings directly; it steers inputs so that existing SSRF detection + * (e.g. Socket/SocketChannel hooks) can observe network connections initiated by XML parsers + * resolving external entities, schemas, or includes. + */ +@Suppress("unused") +object XmlParserSsrfGuidance { + private val EXTERNAL_DOCTYPE = "" + private val EXTERNAL_DOCTYPE_SIZE = EXTERNAL_DOCTYPE.toByteArray().size + + init { + require(EXTERNAL_DOCTYPE_SIZE <= 64) { + "XML exploit must fit in a table of recent compares entry (64 bytes)" + } + } + + // Top-level URI fetch guidance when a systemId is provided as a String. + private const val HTTP_PREFIX = "http://" + private const val HTTPS_PREFIX = "https://" + + private fun guidePossibleXmlStream( + arg: Any?, + hookId: Int, + ) { + when (arg) { + is InputStream -> { + runCatching { + Jazzer.guideTowardsContainment( + String(peekMarkableInputStream(arg, EXTERNAL_DOCTYPE_SIZE)), + EXTERNAL_DOCTYPE, + hookId, + ) + } + } + + is InputSource -> { + arg.byteStream?.let { stream -> + runCatching { + Jazzer.guideTowardsContainment( + String(peekMarkableInputStream(stream, EXTERNAL_DOCTYPE_SIZE)), + EXTERNAL_DOCTYPE, + hookId, + ) + } + } + arg.characterStream?.let { reader -> + runCatching { + Jazzer.guideTowardsContainment( + peekMarkableReader(reader, EXTERNAL_DOCTYPE_SIZE), + EXTERNAL_DOCTYPE, + hookId, + ) + } + } + // If only a systemId is provided, guide it to be a URL. + arg.systemId?.let { guidePossibleUrlString(it, hookId) } + } + + is String -> { + // Some parse APIs accept a systemId/URI as a String. + guidePossibleUrlString(arg, hookId) + } + } + } + + private fun guidePossibleUrlString( + s: String, + hookId: Int, + ) { + Jazzer.guideTowardsContainment(s, HTTP_PREFIX, hookId) + Jazzer.guideTowardsContainment(s, HTTPS_PREFIX, 31 * hookId) + } + + // javax.xml.parsers.DocumentBuilder.parse(...) + @MethodHook( + type = HookType.BEFORE, + targetClassName = "javax.xml.parsers.DocumentBuilder", + targetMethod = "parse", + ) + @JvmStatic + fun guideDocumentBuilderParse( + method: MethodHandle, + thisObject: Any?, + arguments: Array, + hookId: Int, + ) { + if (arguments.isNotEmpty()) { + guidePossibleXmlStream(arguments[0], hookId) + if (arguments.size >= 2) guidePossibleXmlStream(arguments[1], 13 * hookId) + } + } + + @MethodHook( + type = HookType.BEFORE, + targetClassName = "org.xml.sax.XMLReader", + targetMethod = "parse", + ) + @JvmStatic + fun guideXmlReaderParse( + method: MethodHandle, + thisObject: Any?, + arguments: Array, + hookId: Int, + ) { + if (arguments.isNotEmpty()) { + guidePossibleXmlStream(arguments[0], hookId) + } + } + + @MethodHook( + type = HookType.BEFORE, + targetClassName = "javax.xml.parsers.SAXParser", + targetMethod = "parse", + ) + @JvmStatic + fun guideSaxParserParse( + method: MethodHandle, + thisObject: Any?, + arguments: Array, + hookId: Int, + ) { + if (arguments.isNotEmpty()) { + // First arg is usually the source (InputStream, InputSource, File, or String systemId). + guidePossibleXmlStream(arguments[0], hookId) + if (arguments.size >= 3) { + // There is an overload parse(InputStream, HandlerBase/DefaultHandler, String systemId) + guidePossibleXmlStream(arguments[2], 17 * hookId) + } + } + } + + @MethodHooks( + MethodHook( + type = HookType.BEFORE, + targetClassName = "javax.xml.stream.XMLInputFactory", + targetMethod = "createXMLStreamReader", + ), + MethodHook( + type = HookType.BEFORE, + targetClassName = "javax.xml.stream.XMLInputFactory", + targetMethod = "createXMLEventReader", + ), + ) + @JvmStatic + fun guideStaxCreateReader( + method: MethodHandle, + thisObject: Any?, + arguments: Array, + hookId: Int, + ) { + if (arguments.isNotEmpty()) { + guidePossibleXmlStream(arguments[0], hookId) + if (arguments.size >= 2) guidePossibleXmlStream(arguments[1], 19 * hookId) + } + } +} diff --git a/sanitizers/src/test/java/com/example/BUILD.bazel b/sanitizers/src/test/java/com/example/BUILD.bazel index 6df1c3dd2..038080a63 100644 --- a/sanitizers/src/test/java/com/example/BUILD.bazel +++ b/sanitizers/src/test/java/com/example/BUILD.bazel @@ -561,6 +561,19 @@ java_fuzz_target_test( verify_crash_reproducer = False, ) +java_fuzz_target_test( + name = "SsrfXmlParser", + srcs = [ + "SsrfXmlParser.java", + ], + allowed_findings = [ + "com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium", + ], + tags = ["dangerous"], + target_class = "com.example.SsrfXmlParser", + verify_crash_reproducer = False, +) + java_fuzz_target_test( name = "ScriptEngineInjection", srcs = [ diff --git a/sanitizers/src/test/java/com/example/SsrfXmlParser.java b/sanitizers/src/test/java/com/example/SsrfXmlParser.java new file mode 100644 index 000000000..7ed50eb3b --- /dev/null +++ b/sanitizers/src/test/java/com/example/SsrfXmlParser.java @@ -0,0 +1,39 @@ +/* + * Copyright 2025 Code Intelligence GmbH + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.example; + +import java.io.StringReader; +import javax.xml.parsers.SAXParserFactory; +import org.xml.sax.InputSource; +import org.xml.sax.XMLReader; +import org.xml.sax.helpers.DefaultHandler; + +public class SsrfXmlParser { + + public static void fuzzerTestOneInput(byte[] data) throws Exception { + SAXParserFactory factory = SAXParserFactory.newInstance(); + // Default XML parser supports looking up external entities. + // Disallow all doctype declarations if XML is not trusted: + // factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + XMLReader parser = factory.newSAXParser().getXMLReader(); + parser.setErrorHandler(new DefaultHandler() {}); + try { + parser.parse(new InputSource(new StringReader(new String(data)))); + } catch (Exception ignored) { + } + } +}