Skip to content

Commit 428bb15

Browse files
jandro996devflow.devflow-routing-intake
andauthored
Fix Metaspace leak in SCA Reachability retransform and opcode injection (#12013)
Fix Metaspace leak in SCA Reachability (APPSEC-69201) - Bound retransformClasses() retries: track per-class failure counts and give up after MAX_RETRANSFORM_ATTEMPTS instead of re-queueing failed classes forever, which retained Class<?>/ClassLoader references indefinitely. - Cover all 13 ASM MethodVisitor visitXxxInsn callbacks in ScaMethodCallbackInjector.MethodEntryInjector so the entry-point injection triggers on the first bytecode instruction regardless of its opcode kind, instead of only on the 4 previously handled ones. Bisect failing retransform batches instead of coupling them by failure count Split pendingRetransform into independent per-batch queue entries and retransform each batch separately; on failure, bisect multi-class batches into two halves deferred to the next heartbeat instead of tying an unrelated healthy class to a permanently-failing one. Simplify singleton-batch retransform retry to drop-on-first-failure Replace the retransformFailureCount/MAX_RETRANSFORM_ATTEMPTS bounded retry with an immediate drop once a batch is narrowed down to a single class, per the performance-over-detection trade-off agreed with mcculls on PR #12013. Batch already-loaded classes as one shared retransform group checkAlreadyLoadedClasses() previously queued each already-loaded vulnerable class as its own singleton batch, turning the common all-succeed startup path into one retransformClasses() call per class instead of one call for all of them. Queue them as a single shared batch instead; bisection still applies on the next heartbeat if that batch fails. Found by Codex review on PR #12013. Add test coverage for remaining MethodEntryInjector opcode overrides jacocoTestCoverageVerification failed: visitIntInsn/visitJumpInsn/visitIincInsn/ visitTableSwitchInsn/visitLookupSwitchInsn/visitMultiANewArrayInsn were never exercised by any test, leaving instruction coverage at 0.7 against the 0.8 minimum for the appsec module. Remove non-modifiable classes from batch in place instead of copying to a second list Addresses mccull's review comment: mutate the polled batch directly via removeIf() instead of allocating a second ArrayList to hold the modifiable classes. Updates tests that seeded pendingRetransform with immutable singletonList() batches, which is no longer valid now that batches must support in-place removal. Merge branch 'master' into sca-bug Merge branch 'master' into sca-bug Co-authored-by: devflow.devflow-routing-intake <devflow.devflow-routing-intake@kubernetes.us1.ddbuild.io>
1 parent 0bf04c1 commit 428bb15

5 files changed

Lines changed: 510 additions & 50 deletions

File tree

dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaMethodCallbackInjector.java

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import net.bytebuddy.jar.asm.ClassReader;
77
import net.bytebuddy.jar.asm.ClassVisitor;
88
import net.bytebuddy.jar.asm.ClassWriter;
9+
import net.bytebuddy.jar.asm.Handle;
910
import net.bytebuddy.jar.asm.Label;
1011
import net.bytebuddy.jar.asm.MethodVisitor;
1112
import net.bytebuddy.jar.asm.Opcodes;
@@ -100,6 +101,65 @@ public void visitFieldInsn(int opcode, String owner, String name, String descrip
100101
super.visitFieldInsn(opcode, owner, name, descriptor);
101102
}
102103

104+
@Override
105+
public void visitTypeInsn(int opcode, String type) {
106+
ensureInjected();
107+
super.visitTypeInsn(opcode, type);
108+
}
109+
110+
@Override
111+
public void visitIntInsn(int opcode, int operand) {
112+
ensureInjected();
113+
super.visitIntInsn(opcode, operand);
114+
}
115+
116+
@Override
117+
public void visitLdcInsn(Object value) {
118+
ensureInjected();
119+
super.visitLdcInsn(value);
120+
}
121+
122+
@Override
123+
public void visitJumpInsn(int opcode, Label label) {
124+
ensureInjected();
125+
super.visitJumpInsn(opcode, label);
126+
}
127+
128+
@Override
129+
public void visitIincInsn(int varIndex, int increment) {
130+
ensureInjected();
131+
super.visitIincInsn(varIndex, increment);
132+
}
133+
134+
@Override
135+
public void visitTableSwitchInsn(int min, int max, Label dflt, Label... labels) {
136+
ensureInjected();
137+
super.visitTableSwitchInsn(min, max, dflt, labels);
138+
}
139+
140+
@Override
141+
public void visitLookupSwitchInsn(Label dflt, int[] keys, Label[] labels) {
142+
ensureInjected();
143+
super.visitLookupSwitchInsn(dflt, keys, labels);
144+
}
145+
146+
@Override
147+
public void visitMultiANewArrayInsn(String descriptor, int numDimensions) {
148+
ensureInjected();
149+
super.visitMultiANewArrayInsn(descriptor, numDimensions);
150+
}
151+
152+
@Override
153+
public void visitInvokeDynamicInsn(
154+
String name,
155+
String descriptor,
156+
Handle bootstrapMethodHandle,
157+
Object... bootstrapMethodArguments) {
158+
ensureInjected();
159+
super.visitInvokeDynamicInsn(
160+
name, descriptor, bootstrapMethodHandle, bootstrapMethodArguments);
161+
}
162+
103163
private void ensureInjected() {
104164
if (!injected) {
105165
injected = true;

dd-java-agent/appsec/src/main/java/com/datadog/appsec/sca/ScaReachabilityTransformer.java

Lines changed: 98 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -77,17 +77,21 @@ public final class ScaReachabilityTransformer implements ClassFileTransformer {
7777
final ConcurrentHashMap<String, Dependency> classpathArtifactCache = new ConcurrentHashMap<>();
7878

7979
/**
80-
* Classes whose bytecode needs (re)transformation for method-level symbol injection:
80+
* Batches of classes whose bytecode needs (re)transformation for method-level symbol injection:
8181
*
8282
* <ul>
83-
* <li>Classes already loaded at startup before this transformer was registered.
84-
* <li>Classes where JAR version resolution returned no results at load time and needs a retry.
83+
* <li>Classes already loaded at startup before this transformer was registered (queued as a
84+
* single shared batch by {@link #checkAlreadyLoadedClasses()}).
85+
* <li>Batches that failed {@code retransformClasses()} and were bisected into halves (see
86+
* {@link #performPendingRetransforms()}).
8587
* </ul>
8688
*
87-
* Drained and processed by {@link #performPendingRetransforms()} on each telemetry heartbeat.
89+
* <p>Each element is retransformed via its own {@code retransformClasses()} call, independently
90+
* of the other batches, so an unrelated failure in one batch never affects another. Drained and
91+
* processed by {@link #performPendingRetransforms()} on each telemetry heartbeat.
8892
*/
8993
@VisibleForTesting
90-
final ConcurrentLinkedQueue<Class<?>> pendingRetransform = new ConcurrentLinkedQueue<>();
94+
final ConcurrentLinkedQueue<List<Class<?>>> pendingRetransform = new ConcurrentLinkedQueue<>();
9195

9296
/** Class names (internal format) queued for deferred retransformation by name lookup. */
9397
@VisibleForTesting final Set<String> pendingRetransformNames = ConcurrentHashMap.newKeySet();
@@ -242,6 +246,7 @@ private byte[] processClass(
242246
* would produce false positives if used as reachability proxies. See APPSEC-62260.
243247
*/
244248
public void checkAlreadyLoadedClasses() {
249+
List<Class<?>> toRetransform = new ArrayList<>();
245250
for (Class<?> clazz : instrumentation.getAllLoadedClasses()) {
246251
if (clazz == null) {
247252
continue;
@@ -264,7 +269,15 @@ public void checkAlreadyLoadedClasses() {
264269
// All symbols are method-level: always schedule retransformation so the bytecode
265270
// callback can be injected. We can't modify bytecode during the startup scan; deferred
266271
// to performPendingRetransforms().
267-
pendingRetransform.add(clazz);
272+
toRetransform.add(clazz);
273+
}
274+
if (!toRetransform.isEmpty()) {
275+
// Queued as a single shared batch: the common case is that all of these classes retransform
276+
// successfully together, so this keeps the startup scan to one retransformClasses() call
277+
// instead of one per already-loaded class. If the batch does fail,
278+
// performPendingRetransforms()
279+
// bisects it on the next heartbeat like any other batch.
280+
pendingRetransform.add(toRetransform);
268281
}
269282
}
270283

@@ -286,21 +299,25 @@ public void performPendingRetransforms() {
286299
if (instrumentation == null) {
287300
return; // no-op when instrumentation is unavailable (e.g. in unit tests)
288301
}
289-
// Drain the direct Class<?> queue (from checkAlreadyLoadedClasses)
290-
List<Class<?>> toRetransform = new ArrayList<>();
291-
Class<?> clazz;
292-
while ((clazz = pendingRetransform.poll()) != null) {
293-
if (instrumentation.isModifiableClass(clazz)) {
294-
toRetransform.add(clazz);
302+
// Drain the batch queue (from checkAlreadyLoadedClasses and prior bisections), dropping any
303+
// classes that are no longer modifiable from within each batch while preserving the rest of
304+
// that batch's grouping.
305+
List<List<Class<?>>> batches = new ArrayList<>();
306+
List<Class<?>> batch;
307+
while ((batch = pendingRetransform.poll()) != null) {
308+
batch.removeIf(c -> !instrumentation.isModifiableClass(c));
309+
if (!batch.isEmpty()) {
310+
batches.add(batch);
295311
}
296312
}
297313

298-
// Resolve any classes queued by name (from processClass timing failures).
299-
// Use contains+removeAll instead of remove inside the loop: the same class may be loaded
300-
// by multiple classloaders (e.g. Spring Boot LaunchedURLClassLoader creates more than one
301-
// instance), and we must retransform ALL of them, not just the first one found.
314+
// Resolve any classes queued by name (from processClass timing failures) into their own
315+
// batch. Use contains+removeAll instead of remove inside the loop: the same class may be
316+
// loaded by multiple classloaders (e.g. Spring Boot LaunchedURLClassLoader creates more than
317+
// one instance), and we must retransform ALL of them, not just the first one found.
302318
if (!pendingRetransformNames.isEmpty()) {
303319
Set<String> matched = new HashSet<>();
320+
List<Class<?>> resolved = new ArrayList<>();
304321
for (Class<?> loaded : instrumentation.getAllLoadedClasses()) {
305322
if (loaded == null) {
306323
continue;
@@ -310,14 +327,17 @@ public void performPendingRetransforms() {
310327
// Always add to matched to drain the pending set; only retransform if modifiable.
311328
matched.add(name);
312329
if (instrumentation.isModifiableClass(loaded)) {
313-
toRetransform.add(loaded);
330+
resolved.add(loaded);
314331
}
315332
}
316333
}
317334
pendingRetransformNames.removeAll(matched);
335+
if (!resolved.isEmpty()) {
336+
batches.add(resolved);
337+
}
318338
}
319339

320-
if (toRetransform.isEmpty()) {
340+
if (batches.isEmpty()) {
321341
return;
322342
}
323343

@@ -329,39 +349,73 @@ public void performPendingRetransforms() {
329349
// spring-boot-starter-web) whose pom.properties is not in the class's own JAR.
330350
// Without pre-warming classpathArtifactCache, this scans all java.class.path JARs via
331351
// resolveDependencies() under JVM locks, reintroducing the snakeyaml deadlock.
332-
for (Class<?> c : toRetransform) {
333-
ProtectionDomain pd = c.getProtectionDomain();
334-
if (pd == null) {
335-
continue;
336-
}
337-
CodeSource cs = pd.getCodeSource();
338-
if (cs == null) {
339-
continue;
340-
}
341-
URL loc = cs.getLocation();
342-
if (loc == null) {
343-
continue;
344-
}
345-
resolveDependencies(loc);
346-
String internalName = c.getName().replace('.', '/');
347-
List<ScaEntry> dbEntries = database.entriesForClass(internalName);
348-
if (dbEntries != null) {
349-
List<Dependency> classJarDeps = resolveDependenciesFromCache(loc);
350-
for (ScaEntry entry : dbEntries) {
351-
resolveVersionForArtifact(entry.artifact(), classJarDeps);
352+
for (List<Class<?>> b : batches) {
353+
for (Class<?> c : b) {
354+
ProtectionDomain pd = c.getProtectionDomain();
355+
if (pd == null) {
356+
continue;
357+
}
358+
CodeSource cs = pd.getCodeSource();
359+
if (cs == null) {
360+
continue;
361+
}
362+
URL loc = cs.getLocation();
363+
if (loc == null) {
364+
continue;
365+
}
366+
resolveDependencies(loc);
367+
String internalName = c.getName().replace('.', '/');
368+
List<ScaEntry> dbEntries = database.entriesForClass(internalName);
369+
if (dbEntries != null) {
370+
List<Dependency> classJarDeps = resolveDependenciesFromCache(loc);
371+
for (ScaEntry entry : dbEntries) {
372+
resolveVersionForArtifact(entry.artifact(), classJarDeps);
373+
}
352374
}
353375
}
354376
}
355377

378+
// Each batch is retransformed independently: a failure in one batch never affects another,
379+
// and a failing multi-class batch is bisected rather than dropped as a whole.
380+
for (List<Class<?>> b : batches) {
381+
retransformBatch(b);
382+
}
383+
}
384+
385+
/**
386+
* Attempts {@code retransformClasses()} for a single batch. On failure, a multi-class batch is
387+
* bisected into two halves that are re-queued as independent batches for the next heartbeat —
388+
* never retried within this same call — so that a healthy class is progressively isolated from an
389+
* unrelated, permanently-failing batch-mate instead of being dropped alongside it. Once a batch
390+
* is down to a single class, a further failure of that class means it has already been proven to
391+
* be the actual cause (or, for a class that started as a singleton, that its very first attempt
392+
* failed): it is dropped immediately rather than retried, so it never leaks its {@code Class<?>}
393+
* (and {@code ClassLoader}) in Metaspace. See APPSEC-69201 for the trade-off this implies.
394+
*/
395+
private void retransformBatch(List<Class<?>> batch) {
356396
try {
357-
instrumentation.retransformClasses(toRetransform.toArray(new Class<?>[0]));
397+
instrumentation.retransformClasses(batch.toArray(new Class<?>[0]));
358398
log.debug(
359-
"SCA Reachability: retransformed {} class(es) for method-level detection",
360-
toRetransform.size());
399+
"SCA Reachability: retransformed {} class(es) for method-level detection", batch.size());
361400
} catch (Throwable t) {
362-
log.debug("SCA Reachability: retransformClasses failed", t);
363-
// Re-queue on failure so the next heartbeat can retry
364-
pendingRetransform.addAll(toRetransform);
401+
log.debug(
402+
"SCA Reachability: retransformClasses failed for a batch of {} class(es)",
403+
batch.size(),
404+
t);
405+
if (batch.size() == 1) {
406+
log.debug(
407+
"SCA Reachability: giving up retransforming {} after a failed attempt as a singleton"
408+
+ " batch",
409+
batch.get(0).getName());
410+
} else {
411+
int mid = batch.size() / 2;
412+
pendingRetransform.add(new ArrayList<>(batch.subList(0, mid)));
413+
pendingRetransform.add(new ArrayList<>(batch.subList(mid, batch.size())));
414+
log.debug(
415+
"SCA Reachability: bisecting failing batch of {} class(es) into two halves for the"
416+
+ " next heartbeat",
417+
batch.size());
418+
}
365419
}
366420
}
367421

0 commit comments

Comments
 (0)