Skip to content

Commit 99d47ca

Browse files
jandro996devflow.devflow-routing-intake
andauthored
Fix ObjectIntrospection exposing JDK internal toString() to the WAF (#10820)
Fix ObjectIntrospection exposing JDK internal toString() to the WAF wip fix test for all jdks Avoid log classes new approach test new approach test change to .trie WIP Merge branch 'master' into alejandro.gonzalez/APPSEC-61693 Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
1 parent 311d3bd commit 99d47ca

File tree

4 files changed

+114
-11
lines changed

4 files changed

+114
-11
lines changed

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)