Skip to content

Commit fccc5be

Browse files
committed
feat: add XML guidance hook
This hook guides the fuzzer to trigger external resource loading during XML parsing, which may trigger a finding reported by the SSRF bug detector.
1 parent f62583b commit fccc5be

File tree

7 files changed

+280
-11
lines changed

7 files changed

+280
-11
lines changed

sanitizers/sanitizers.bzl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ _sanitizer_class_names = [
3434
"SqlInjection",
3535
"UnsafeSanitizer",
3636
"XPathInjection",
37+
"XmlParserSsrfGuidance",
3738
]
3839

3940
SANITIZER_CLASSES = [_sanitizer_package_prefix + class_name for class_name in _sanitizer_class_names]

sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ kt_jvm_library(
8181
"RegexInjection.kt",
8282
"Utils.kt",
8383
"XPathInjection.kt",
84+
"XmlParserSsrfGuidance.kt",
8485
],
8586
visibility = [
8687
"//sanitizers:__pkg__",

sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Deserialization.kt

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
package com.code_intelligence.jazzer.sanitizers
1818

1919
import com.code_intelligence.jazzer.api.HookType
20+
import com.code_intelligence.jazzer.api.Jazzer
2021
import com.code_intelligence.jazzer.api.MethodHook
2122
import com.code_intelligence.jazzer.api.MethodHooks
2223
import java.io.BufferedInputStream
@@ -101,7 +102,11 @@ object Deserialization {
101102
BufferedInputStream(originalInputStream)
102103
}
103104
args[0] = fixedInputStream
104-
guideMarkableInputStreamTowardsEquality(fixedInputStream, OBJECT_INPUT_STREAM_HEADER, hookId)
105+
Jazzer.guideTowardsEquality(
106+
peekMarkableInputStream(fixedInputStream, OBJECT_INPUT_STREAM_HEADER.size),
107+
OBJECT_INPUT_STREAM_HEADER,
108+
hookId,
109+
)
105110
}
106111

107112
/**
@@ -157,6 +162,10 @@ object Deserialization {
157162
) {
158163
val inputStream = inputStreamForObjectInputStream.get()[objectInputStream]
159164
if (inputStream?.markSupported() != true) return
160-
guideMarkableInputStreamTowardsEquality(inputStream, SERIALIZED_JAZ_ZER_INSTANCE, hookId)
165+
Jazzer.guideTowardsEquality(
166+
peekMarkableInputStream(inputStream, SERIALIZED_JAZ_ZER_INSTANCE.size),
167+
SERIALIZED_JAZ_ZER_INSTANCE,
168+
hookId,
169+
)
161170
}
162171
}

sanitizers/src/main/java/com/code_intelligence/jazzer/sanitizers/Utils.kt

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616

1717
package com.code_intelligence.jazzer.sanitizers
1818

19-
import com.code_intelligence.jazzer.api.Jazzer
2019
import java.io.InputStream
20+
import java.io.Reader
2121

2222
/**
2323
* 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 {
4444
return -1
4545
}
4646

47-
internal fun guideMarkableInputStreamTowardsEquality(
47+
internal fun peekMarkableInputStream(
4848
stream: InputStream,
49-
target: ByteArray,
50-
id: Int,
51-
) {
49+
readlimit: Int,
50+
): ByteArray {
5251
fun readBytes(
5352
stream: InputStream,
5453
size: Int,
@@ -62,10 +61,33 @@ internal fun guideMarkableInputStreamTowardsEquality(
6261
}
6362
return if (n >= readlimit) current else current.copyOf(n)
6463
}
65-
6664
check(stream.markSupported())
67-
stream.mark(target.size)
68-
val current = readBytes(stream, target.size)
65+
stream.mark(readlimit)
66+
val content = readBytes(stream, readlimit)
6967
stream.reset()
70-
Jazzer.guideTowardsEquality(current, target, id)
68+
return content
69+
}
70+
71+
internal fun peekMarkableReader(
72+
reader: Reader,
73+
readlimit: Int,
74+
): String {
75+
fun readString(
76+
reader: Reader,
77+
size: Int,
78+
): String {
79+
val current = CharArray(size)
80+
var n = 0
81+
while (n < size) {
82+
val count = reader.read(current, n, size - n)
83+
if (count < 0) break
84+
n += count
85+
}
86+
return String(current, 0, n)
87+
}
88+
check(reader.markSupported())
89+
reader.mark(readlimit)
90+
val content = readString(reader, readlimit)
91+
reader.reset()
92+
return content
7193
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
/*
2+
* Copyright 2025 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.code_intelligence.jazzer.sanitizers
18+
19+
import com.code_intelligence.jazzer.api.HookType
20+
import com.code_intelligence.jazzer.api.Jazzer
21+
import com.code_intelligence.jazzer.api.MethodHook
22+
import com.code_intelligence.jazzer.api.MethodHooks
23+
import org.xml.sax.InputSource
24+
import java.io.InputStream
25+
import java.lang.invoke.MethodHandle
26+
27+
/**
28+
* Guides XML parser entry points towards patterns that can trigger external resource fetching.
29+
*
30+
* This does not report findings directly; it steers inputs so that existing SSRF detection
31+
* (e.g. Socket/SocketChannel hooks) can observe network connections initiated by XML parsers
32+
* resolving external entities, schemas, or includes.
33+
*/
34+
@Suppress("unused")
35+
object XmlParserSsrfGuidance {
36+
private val EXTERNAL_DOCTYPE = "<!DOCTYPE x PUBLIC \"\" \"http://foo\">"
37+
private val EXTERNAL_DOCTYPE_SIZE = EXTERNAL_DOCTYPE.toByteArray().size
38+
39+
init {
40+
require(EXTERNAL_DOCTYPE_SIZE <= 64) {
41+
"XML exploit must fit in a table of recent compares entry (64 bytes)"
42+
}
43+
}
44+
45+
// Top-level URI fetch guidance when a systemId is provided as a String.
46+
private const val HTTP_PREFIX = "http://"
47+
private const val HTTPS_PREFIX = "https://"
48+
49+
private fun guidePossibleXmlStream(
50+
arg: Any?,
51+
hookId: Int,
52+
) {
53+
when (arg) {
54+
is InputStream -> {
55+
runCatching {
56+
Jazzer.guideTowardsContainment(
57+
String(peekMarkableInputStream(arg, EXTERNAL_DOCTYPE_SIZE)),
58+
EXTERNAL_DOCTYPE,
59+
hookId,
60+
)
61+
}
62+
}
63+
64+
is InputSource -> {
65+
arg.byteStream?.let { stream ->
66+
runCatching {
67+
Jazzer.guideTowardsContainment(
68+
String(peekMarkableInputStream(stream, EXTERNAL_DOCTYPE_SIZE)),
69+
EXTERNAL_DOCTYPE,
70+
hookId,
71+
)
72+
}
73+
}
74+
arg.characterStream?.let { reader ->
75+
runCatching {
76+
Jazzer.guideTowardsContainment(
77+
peekMarkableReader(reader, EXTERNAL_DOCTYPE_SIZE),
78+
EXTERNAL_DOCTYPE,
79+
hookId,
80+
)
81+
}
82+
}
83+
// If only a systemId is provided, guide it to be a URL.
84+
arg.systemId?.let { guidePossibleUrlString(it, hookId) }
85+
}
86+
87+
is String -> {
88+
// Some parse APIs accept a systemId/URI as a String.
89+
guidePossibleUrlString(arg, hookId)
90+
}
91+
}
92+
}
93+
94+
private fun guidePossibleUrlString(
95+
s: String,
96+
hookId: Int,
97+
) {
98+
Jazzer.guideTowardsContainment(s, HTTP_PREFIX, hookId)
99+
Jazzer.guideTowardsContainment(s, HTTPS_PREFIX, 31 * hookId)
100+
}
101+
102+
// javax.xml.parsers.DocumentBuilder.parse(...)
103+
@MethodHook(
104+
type = HookType.BEFORE,
105+
targetClassName = "javax.xml.parsers.DocumentBuilder",
106+
targetMethod = "parse",
107+
)
108+
@JvmStatic
109+
fun guideDocumentBuilderParse(
110+
method: MethodHandle,
111+
thisObject: Any?,
112+
arguments: Array<Any>,
113+
hookId: Int,
114+
) {
115+
if (arguments.isNotEmpty()) {
116+
guidePossibleXmlStream(arguments[0], hookId)
117+
if (arguments.size >= 2) guidePossibleXmlStream(arguments[1], 13 * hookId)
118+
}
119+
}
120+
121+
@MethodHook(
122+
type = HookType.BEFORE,
123+
targetClassName = "org.xml.sax.XMLReader",
124+
targetMethod = "parse",
125+
)
126+
@JvmStatic
127+
fun guideXmlReaderParse(
128+
method: MethodHandle,
129+
thisObject: Any?,
130+
arguments: Array<Any>,
131+
hookId: Int,
132+
) {
133+
if (arguments.isNotEmpty()) {
134+
guidePossibleXmlStream(arguments[0], hookId)
135+
}
136+
}
137+
138+
@MethodHook(
139+
type = HookType.BEFORE,
140+
targetClassName = "javax.xml.parsers.SAXParser",
141+
targetMethod = "parse",
142+
)
143+
@JvmStatic
144+
fun guideSaxParserParse(
145+
method: MethodHandle,
146+
thisObject: Any?,
147+
arguments: Array<Any>,
148+
hookId: Int,
149+
) {
150+
if (arguments.isNotEmpty()) {
151+
// First arg is usually the source (InputStream, InputSource, File, or String systemId).
152+
guidePossibleXmlStream(arguments[0], hookId)
153+
if (arguments.size >= 3) {
154+
// There is an overload parse(InputStream, HandlerBase/DefaultHandler, String systemId)
155+
guidePossibleXmlStream(arguments[2], 17 * hookId)
156+
}
157+
}
158+
}
159+
160+
@MethodHooks(
161+
MethodHook(
162+
type = HookType.BEFORE,
163+
targetClassName = "javax.xml.stream.XMLInputFactory",
164+
targetMethod = "createXMLStreamReader",
165+
),
166+
MethodHook(
167+
type = HookType.BEFORE,
168+
targetClassName = "javax.xml.stream.XMLInputFactory",
169+
targetMethod = "createXMLEventReader",
170+
),
171+
)
172+
@JvmStatic
173+
fun guideStaxCreateReader(
174+
method: MethodHandle,
175+
thisObject: Any?,
176+
arguments: Array<Any>,
177+
hookId: Int,
178+
) {
179+
if (arguments.isNotEmpty()) {
180+
guidePossibleXmlStream(arguments[0], hookId)
181+
if (arguments.size >= 2) guidePossibleXmlStream(arguments[1], 19 * hookId)
182+
}
183+
}
184+
}

sanitizers/src/test/java/com/example/BUILD.bazel

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,6 +561,19 @@ java_fuzz_target_test(
561561
verify_crash_reproducer = False,
562562
)
563563

564+
java_fuzz_target_test(
565+
name = "SsrfXmlParser",
566+
srcs = [
567+
"SsrfXmlParser.java",
568+
],
569+
allowed_findings = [
570+
"com.code_intelligence.jazzer.api.FuzzerSecurityIssueMedium",
571+
],
572+
tags = ["dangerous"],
573+
target_class = "com.example.SsrfXmlParser",
574+
verify_crash_reproducer = False,
575+
)
576+
564577
java_fuzz_target_test(
565578
name = "ScriptEngineInjection",
566579
srcs = [
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Copyright 2025 Code Intelligence GmbH
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package com.example;
18+
19+
import java.io.StringReader;
20+
import javax.xml.parsers.SAXParserFactory;
21+
import org.xml.sax.InputSource;
22+
import org.xml.sax.XMLReader;
23+
import org.xml.sax.helpers.DefaultHandler;
24+
25+
public class SsrfXmlParser {
26+
27+
public static void fuzzerTestOneInput(byte[] data) throws Exception {
28+
SAXParserFactory factory = SAXParserFactory.newInstance();
29+
// Default XML parser supports looking up external entities.
30+
// Disallow all doctype declarations if XML is not trusted:
31+
// factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
32+
XMLReader parser = factory.newSAXParser().getXMLReader();
33+
parser.setErrorHandler(new DefaultHandler() {});
34+
try {
35+
parser.parse(new InputSource(new StringReader(new String(data))));
36+
} catch (Exception ignored) {
37+
}
38+
}
39+
}

0 commit comments

Comments
 (0)