Skip to content

Commit 1a88b33

Browse files
authored
Fix ObjectIntrospection exposing JDK internal toString() to the WAF (#10820) (#10882)
Backport #10820 to release/v1.60.x
1 parent 8d25a5b commit 1a88b33

4 files changed

Lines changed: 114 additions & 11 deletions

File tree

dd-java-agent/appsec/build.gradle

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ plugins {
99

1010
apply from: "$rootDir/gradle/java.gradle"
1111
apply from: "$rootDir/gradle/version.gradle"
12+
apply from: "$rootDir/gradle/tries.gradle"
1213

1314
dependencies {
1415
api libs.slf4j
@@ -29,6 +30,14 @@ dependencies {
2930
testImplementation libs.jackson.databind
3031
}
3132

33+
tasks.named("compileJava", JavaCompile) {
34+
dependsOn("generateClassNameTries")
35+
}
36+
37+
tasks.named("sourcesJar", Jar) {
38+
dependsOn("generateClassNameTries")
39+
}
40+
3241
tasks.named("shadowJar", ShadowJar) {
3342
exclude '**/*-dbgsym.zip'
3443
dependencies deps.excludeShared

dd-java-agent/appsec/src/main/java/com/datadog/appsec/event/data/ObjectIntrospection.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ private static Object doConversion(Object obj, int depth, State state) {
287287
if (Modifier.isStatic(f.getModifiers())) {
288288
continue;
289289
}
290-
if (f.getType().getName().equals("groovy.lang.MetaClass")) {
290+
if (IntrospectionExcludedTypesTrie.apply(f.getType().getName()) >= 1) {
291291
continue;
292292
}
293293
String name = f.getName();
@@ -302,12 +302,13 @@ private static Object doConversion(Object obj, int depth, State state) {
302302
log.error("Unable to get field value", e);
303303
// TODO: Use invalid object
304304
}
305-
} else {
306-
// One of fields is inaccessible, might be it's Strongly Encapsulated Internal class
307-
// consider it as integral object without introspection
308-
// TODO: Use invalid object
309-
return obj.toString();
310305
}
306+
// This field is inaccessible (Strongly Encapsulated Internal class on Java 9+).
307+
// Skip it and continue with the remaining fields — other accessible fields on the
308+
// same object may still contain useful data for WAF inspection. Do NOT call
309+
// obj.toString() here: JDK internal toString() representations (e.g.
310+
// "class java.lang.Object") can match legitimate WAF phrase_match rules and
311+
// produce false positives (e.g. crs-944-130 java_code_injection).
311312
}
312313
}
313314

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# Generates 'IntrospectionExcludedTypesTrie.java'
2+
3+
# Field types excluded from ObjectIntrospection to avoid deep/cyclic traversals
4+
# that could trigger WAF false positives (e.g. crs-944-130 java_code_injection).
5+
# 1 = exclude this field type
6+
7+
# -------- Groovy --------
8+
1 groovy.lang.MetaClass
9+
10+
# -------- Logging frameworks --------
11+
1 ch.qos.logback.*
12+
1 java.util.logging.Logger
13+
1 org.apache.commons.logging.Log
14+
1 org.apache.logging.*
15+
1 org.slf4j.*

dd-java-agent/appsec/src/test/groovy/com/datadog/appsec/event/data/ObjectIntrospectionSpecification.groovy

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
164164
void 'max number of elements is honored'() {
165165
setup:
166166
def m = [:]
167-
128.times { m[it] = 'b' }
167+
128.times {
168+
m[it] = 'b'
169+
}
168170

169171
when:
170172
def result1 = convert([['a'] * 255], ctx)[0]
@@ -184,7 +186,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
184186
// Build a nested array 22 levels deep
185187
Object[] objArray = new Object[1]
186188
def p = objArray
187-
22.times { p = p[0] = new Object[1] }
189+
22.times {
190+
p = p[0] = new Object[1]
191+
}
188192

189193
when:
190194
// Invoke conversion with context
@@ -208,7 +212,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
208212
// Build a nested list 22 levels deep
209213
def list = []
210214
def p = list
211-
22.times { p << []; p = p[0] }
215+
22.times {
216+
p << []; p = p[0]
217+
}
212218

213219
when:
214220
// Invoke conversion with context
@@ -232,7 +238,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
232238
// Build a nested map 22 levels deep under key 'a'
233239
def map = [:]
234240
def p = map
235-
22.times { p['a'] = [:]; p = p['a'] }
241+
22.times {
242+
p['a'] = [:]; p = p['a']
243+
}
236244

237245
when:
238246
// Invoke conversion with context
@@ -413,7 +421,9 @@ class ObjectIntrospectionSpecification extends DDSpecification {
413421
setup:
414422
// Create deeply nested JSON
415423
final json = JsonOutput.toJson(
416-
(1..(MAX_DEPTH + 1)).inject([:], { result, i -> [("child_$i".toString()) : result] })
424+
(1..(MAX_DEPTH + 1)).inject([:], {
425+
result, i -> [("child_$i".toString()) : result]
426+
})
417427
)
418428

419429
when:
@@ -478,6 +488,74 @@ class ObjectIntrospectionSpecification extends DDSpecification {
478488
MAPPER.readTree('"unicode: \\u0041"') || 'unicode: A'
479489
}
480490

491+
void 'logging framework fields are excluded from introspection'() {
492+
given: '''Reproduces the false positive triggered by crs-944-130 (java_code_injection).
493+
A request body DTO had an instance field of a concrete logging type. ObjectIntrospection
494+
traversed the logger internals 19 levels deep (SLF4J → Log4jLogger → core.Logger →
495+
privateConfig → loggerConfig → appenders → appenderArray → appender → manager →
496+
layout → objectWriter → _config → _base → _typeFactory → _typeCache → _map →
497+
invalid_key:9 → _elementType → _class), eventually calling
498+
java.lang.Class.toString() = "class java.lang.object" which phrase-matched the WAF rule.
499+
The field in the real trace was declared as org.slf4j.Logger (runtime: Log4jLogger),
500+
but the trie also covers org.apache.logging.slf4j.Log4jLogger directly via
501+
org.apache.logging.* in case the field is declared as the concrete class.'''
502+
def input = new DtoWithLogger()
503+
504+
when:
505+
def result = convert(input, ctx) as Map
506+
507+
then:
508+
result['userId'] == 'user123'
509+
result['payload'] == 'data'
510+
!result.containsKey('logger')
511+
}
512+
513+
static class DtoWithLogger {
514+
String userId = 'user123'
515+
// Declared as the concrete Logback type (ch.qos.logback.classic.Logger) to mirror the
516+
// real scenario where org.apache.logging.slf4j.Log4jLogger is the runtime instance of
517+
// a field declared as the concrete logger class rather than the SLF4J interface.
518+
// Both are covered by the trie (ch.qos.logback.* and org.apache.logging.*).
519+
ch.qos.logback.classic.Logger logger = (ch.qos.logback.classic.Logger) org.slf4j.LoggerFactory.getLogger('test')
520+
String payload = 'data'
521+
}
522+
523+
void 'objects with inaccessible JDK fields skip those fields rather than expose toString()'() {
524+
given: '''An object with two fields: one normal, one holding a java.lang.ref.SoftReference.
525+
java.lang.ref is NOT opened in the test JVM (only java.lang and java.util are),
526+
so trySetAccessible() returns false for SoftReference's own fields on Java 9+.
527+
528+
Bug: ObjectIntrospection fell back to obj.toString() when any field was inaccessible,
529+
exposing internal JDK string representations to the WAF and discarding all other
530+
accessible fields on the same object. For example, java.lang.Class.toString() produces
531+
"class java.lang.Object" which matches WAF phrase_match rule crs-944-130
532+
(java_code_injection) — a false positive causing a CPU spike.
533+
534+
Fix: skip inaccessible fields (continue) instead of aborting the whole object.
535+
Accessible fields on the same object are still reported to the WAF.'''
536+
def input = new WrapperWithSoftRef()
537+
538+
when:
539+
def result = convert(input, ctx) as Map
540+
541+
then:
542+
// The accessible field 'name' must be preserved regardless of JVM version
543+
result['name'] == 'test'
544+
// The inaccessible-field object must NOT expose toString() to the WAF.
545+
// On JDK 8 and JDK 9-15 (--illegal-access=permit default), java.lang.ref fields are
546+
// accessible so result['ref'] is a non-empty Map. On JDK 16+ strict module enforcement,
547+
// result['ref'] is an empty Map [:] since all Reference fields are inaccessible.
548+
// Before fix (any version where fields are inaccessible): result['ref'] is a String
549+
// e.g. "java.lang.ref.SoftReference@..." — a false WAF positive.
550+
// After fix: result['ref'] is always a Map, never a String.
551+
result['ref'] instanceof Map
552+
}
553+
554+
static class WrapperWithSoftRef {
555+
String name = 'test'
556+
java.lang.ref.SoftReference<String> ref = new java.lang.ref.SoftReference<>("test")
557+
}
558+
481559
void 'iterable json objects'() {
482560
setup:
483561
final map = [name: 'This is just a test', list: [1, 2, 3, 4, 5]]

0 commit comments

Comments
 (0)