Safeguard
Engineering

Four Ways a Java Agent Instruments Nothing and Tells You It Worked

Class loader boundaries, a class literal that cannot resolve, advice read as a resource, and a re-entrancy guard that sticks. All four produce an agent that attaches cleanly and finds nothing.

Marcus Chen
DevSecOps Engineer
6 min read

All four of these failures look identical from the outside. The agent attaches, the JVM starts, the log line says the transformer was installed, and no sink ever fires. Nothing throws. Nothing warns. The build is green and the tool reports a clean application.

We hit all four building a JVM instrumentation agent that hooks JDK sinks: JDBC statements, file streams, process execution. They generalise to any -javaagent that instruments classes the bootstrap loader owns. If your agent is finding nothing, check these before you go looking at your detection logic, because the detection logic is usually fine.

One: the premain runs on a different loader than the code it weaves

premain is loaded by the system class loader. The JDK classes you are instrumenting are loaded by the bootstrap loader. Woven code runs in the bootstrap context, and bootstrap cannot see system.

So any call between the two that passes a type defined by your agent fails a loader constraint check and throws LinkageError at link time, inside a JDK class, during startup. Depending on where it lands you may not see it at all.

The rule that follows is strict and easy to violate by accident: the premain class must reference no agent type whatsoever. Not a logger, not a config object, not an enum. Only JDK types.

public final class Premain {
    public static void premain(String args, Instrumentation inst) throws Exception {
        // Only JDK types are named here. Nothing from this agent.
        inst.appendToBootstrapClassLoaderSearch(new JarFile(agentJarPath()));

        Class<?> installer = Class.forName(
            "com.example.agent.boot.Installer", true, null);  // null loader = bootstrap
        installer.getMethod("install", Instrumentation.class)
                 .invoke(null, inst);
    }
}

Append the jar to the bootstrap search path, then reach the real installer by name through the bootstrap loader. Everything after that line is bootstrap loaded and can talk to woven code freely.

An import statement is enough to break this. A field type is enough. Review the premain class for what it names, not for what it calls.

Two: java.sql is not on the bootstrap loader

Since the module system, java.sql lives in the platform loader, a level below bootstrap in visibility. A bootstrap loaded class that uses a java.sql.Statement.class literal throws NoClassDefFoundError when that literal resolves.

This is maddening because the code compiles, the type obviously exists, and the failure is at the one point you cannot easily breakpoint.

Match by name instead of by class literal:

// Fails from a bootstrap-loaded class:
.type(ElementMatchers.isSubTypeOf(java.sql.Statement.class))

// Works:
.type(ElementMatchers.hasSuperType(ElementMatchers.named("java.sql.Statement")))

The same applies to anything else outside java.base. When in doubt, match by name. It costs a little matcher performance and it removes an entire class of resolution failure.

Three: Byte Buddy loads your advice as a resource, and bootstrap serves no resources

Advice.to(SomeAdvice.class) does not use the class object the way you would expect. It reads the advice class's bytecode, via getResourceAsStream, so it can inline the method body into the target.

The bootstrap loader does not serve getResourceAsStream. It returns null. So every transformation fails with Could not locate class file for ..., which Byte Buddy reports through the listener you may not have registered, and the net effect is a transformer that matches classes and transforms none of them.

Pass an explicit locator for your own jar:

ClassFileLocator locator = ClassFileLocator.ForJarFile.of(new File(agentJarPath()));

new AgentBuilder.Default()
    .type(hasSuperType(named("java.sql.Statement")))
    .transform((builder, td, cl, module, pd) ->
        builder.visit(Advice.to(StatementAdvice.class, locator).on(named("execute"))))
    .installOn(inst);

And register AgentBuilder.Listener.StreamWriting.toSystemError() during development. Most of the silence in agent development is a listener nobody attached.

Four: @Advice.OnMethodExit does not run when the method throws

By default, exit advice runs on normal return only. To run on exceptional exit you must say so:

@Advice.OnMethodExit(onThrowable = Throwable.class)
static void exit(@Advice.Thrown Throwable thrown) { ... }

This is documented. It is still the worst of the four, because of what it interacts with.

Instrumentation needs a re-entrancy guard, usually a thread local set on entry and cleared on exit, so that the agent's own work does not trigger the agent. If the clear happens only in a normal exit, then the first exceptional exit leaves the guard set forever on that thread. Every subsequent sink on that thread is suppressed. Silently.

Now consider which methods you instrumented. JDK methods throw constantly in ordinary operation. new FileInputStream(...) throws FileNotFoundException on every miss during classpath probing, which is hundreds of times during startup of a typical application. So the guard sticks during boot, before your application has served a single request, and the agent spends the rest of its life reporting nothing.

The tell for this one: it works in a small reproduction and finds nothing in a real application. That is the shape of a stuck guard.

Clear the guard in a finally-equivalent, which means exit advice with onThrowable set, and write the test that throws.

Why your test suite will not catch any of this

A JUnit test that loads the agent in-process exercises a different thing than the shipped agent.

The runner loads the test class and everything it references before @BeforeAll runs. So the classes you meant to instrument are already loaded, and the agent's state exists in a second copy under the system loader, which is precisely the loader separation that traps one and two are about. The suite passes on an agent that cannot work.

Verify with a real subprocess:

java -javaagent:build/libs/agent.jar \
     -cp build/fixtures.jar com.example.Fixture \
  | tee /tmp/agent-run.log

grep -q "SINK jdbc" /tmp/agent-run.log || { echo "no sinks fired"; exit 1; }

It is slower and it is the only test that tests what ships. Keep the in-process tests for detection logic and never let them stand in for an attach test.

The order to debug in

When findings are empty:

  1. Attach the Byte Buddy listener and check whether any class matched. If none matched, it is a matcher problem, probably trap two.
  2. If classes matched but none transformed, it is trap three.
  3. If transforms succeeded but nothing fires, check the guard, which is trap four.
  4. If the JVM logs a LinkageError anywhere near startup, it is trap one.

The concession

Instrumenting ordinary application classes avoids most of this. They are on the same loader as your agent, resources resolve normally, and only trap four still applies. If you can get your signal from application code, do that, and keep JDK instrumentation for the sinks that genuinely only exist there.

The implication

Every one of these produces a working-looking agent. That is the whole cost: a crash would have been cheaper, because a crash points at the line. Silence points nowhere, and the natural reaction to an agent that finds nothing is to suspect the detection rules, which is the one place the bug is not.

So build the loud version first. Attach the listener, log the matches, count the transforms, and assert a known sink fires in a real attached subprocess before writing a single detection rule.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.