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
1 change: 1 addition & 0 deletions sanitizers/sanitizers.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ kt_jvm_library(
"RegexInjection.kt",
"Utils.kt",
"XPathInjection.kt",
"XmlParserSsrfGuidance.kt",
],
visibility = [
"//sanitizers:__pkg__",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
)
}

/**
Expand Down Expand Up @@ -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,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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,
Expand All @@ -60,12 +59,35 @@ internal fun guideMarkableInputStreamTowardsEquality(
if (count < 0) break
n += count
}
return current
return if (n >= readlimit) current else current.copyOf(n)
Copy link
Contributor

@Marcono1234 Marcono1234 Nov 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really minor: Shouldn't this use size instead of readlimit for consistency, since size is the parameter of this local function (and it just happens to be called with readlimit as value)?

Also, would it make sense to switch the branches? Due to Copilot's suggestion to use >=, this statement looks a bit confusing now. What about this?

Suggested change
return if (n >= readlimit) current else current.copyOf(n)
return if (n < size) current.copyOf(n) else current

(Sorry if this comment is distruptive; please let me know in that case)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not disruptive at all. I completely agree. :)
I created a small PR: #1011

}

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
}
Original file line number Diff line number Diff line change
@@ -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 = "<!DOCTYPE x PUBLIC \"\" \"http://foo\">"
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<Any>,
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<Any>,
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<Any>,
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<Any>,
hookId: Int,
) {
if (arguments.isNotEmpty()) {
guidePossibleXmlStream(arguments[0], hookId)
if (arguments.size >= 2) guidePossibleXmlStream(arguments[1], 19 * hookId)
}
}
}
13 changes: 13 additions & 0 deletions sanitizers/src/test/java/com/example/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
39 changes: 39 additions & 0 deletions sanitizers/src/test/java/com/example/SsrfXmlParser.java
Original file line number Diff line number Diff line change
@@ -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) {
}
}
}