Skip to content

CWE-470: Use of Externally-Controlled Input to Select Classes or Code ('Unsafe Reflection')

Overview

Unsafe reflection occurs when an application uses untrusted input to select which class, method or script to load and run - Class.forName, getMethod, a dynamic import, a name looked up in a script engine's bindings. The input names something rather than being something, which is why it reads as configuration. The effect is that the caller chooses from everything the runtime can resolve - every class on the classpath and every method those classes expose - rather than the handful of options the parameter was written for.

Relationship to Other CWEs

OWASP Classification

A05:2025 - Injection

Risk

Critical: Unsafe reflection lets the caller choose which class the application loads, and where the method name is attacker-chosen too, which method then runs on it. The reachable classes are every one on the classpath, so the severity is set by what the dependency tree happens to contain rather than by the endpoint's intent. A class whose constructor or static initializer opens a connection, reads a file, or performs a JNDI lookup is enough, and code the attacker can place on the classpath (CWE-427) removes the constraint entirely.

Remediation Steps

Core Principle: Never allow untrusted input to select classes/types/methods for execution; map to an explicit allowlist or factory.

Locate the unsafe reflection vulnerability

  • Start from the reported line and find where untrusted input selects the class or code
  • Identify the source: where the class name, method name or code comes from - user input, HTTP parameters, external files
  • Trace it to the sink: Class.forName(), getMethod(), ScriptEngine bindings, dynamic imports, getattr, Activator.CreateInstance
  • Work out what an attacker reaches from there: which classes can be instantiated, which methods invoked

Bind names to classes, do not check them (Primary Defense)

  • Define an explicit map from permitted input values to classes: Map<String, Class<?>> ALLOWED = Map.of("user", UserHandler.class, "admin", AdminHandler.class). The key is the value the caller sends, not the class's own name, so no reflection API ever sees attacker input
  • Look the value up defensively. Class<?> clazz = ALLOWED.get(userInput) throws NullPointerException when userInput is null, because Map.of returns a null-hostile map - and a missing request parameter is null. Measured on JDK 26: Map.of(...).get(null) throws, while HashMap.get(null) returns null. Reject a null or blank value before the lookup, or the null check written on the next line never runs and an absent parameter becomes a 500
  • Don't pass user input to Class.forName(), getMethod() or a script-engine lookup without going through the map
  • Where a fixed set of objects will do, map the input to predefined instances through a factory rather than reflecting at all

Why this works: the map is the binding, so there is nothing for a permitted-name list and a resolver to drift apart about. A class renamed, moved, shadowed by a subclass, or added to a package by a dependency changes neither the reachable set nor the keys.

Avoid reflection from user input entirely

  • Use a factory: Map<String, Supplier<Handler>> handlers = Map.of("type1", Type1Handler::new);, looked up in two steps - Supplier<Handler> s = handlers.get(key); if (s == null) throw new IllegalArgumentException(); Handler h = s.get();. Written as the one-liner handlers.get(userInput).get() it throws NullPointerException for every unrecognised key, so an ordinary bad request arrives as a 500
  • Use a strategy pattern: define the interface, map the caller's value to an implementation, and nothing is resolved by name
  • Use dependency injection: predefined beans rather than dynamic class loading
  • Never evaluate user-supplied code with eval() or a ScriptEngine. That is CWE-95 rather than this weakness, and the fix is different

If reflection is genuinely unavoidable, validate the name before resolving it

The order is the whole of it. Every check that operates on a Class object runs after the class has been loaded, and loading runs its static initializer - measured on JDK 26, Class.forName("Evil") printed the initializer's output and only then returned a class that isAssignableFrom rejected. By that point attacker-chosen code has already run.

  • Validate the name first, as a string: if (!className.matches("^com\\.example\\.safe\\.[A-Za-z0-9]+$")) throw new SecurityException(...) - String.matches requires the whole input, so no trailing-newline variant slips past (confirmed on JDK 26). This bounds the damage but does not remove it: everything under the permitted package is still reachable, including classes a dependency adds there later.
  • Load without initializing, then check the type: Class<?> c = Class.forName(name, false, loader); if (!SafeInterface.class.isAssignableFrom(c)) throw new SecurityException(...). The three-argument form with initialize = false links the class without running its static initializer - measured, the initializer did not run and isAssignableFrom still answered correctly - so the type check happens before any of the class's own code does.
  • Check class annotations: verify the class carries @Safe or a similar marker, again on a class loaded with initialize = false
  • Do not rely on a list of dangerous classes. A denylist naming Runtime, ProcessBuilder, URLClassLoader, ScriptEngine and Unsafe is worth writing as a tripwire that logs and alerts, and it is not a control. The reachable set is every class on the classpath, it grows with every dependency added, and a blocklist has to name each gadget in advance - while several of the entries people reach for first are ones the JVM would refuse anyway (see the measurement under A class chosen by the caller), so the list gives less cover than its length suggests. The bounded answer is the map above.

Monitor and audit reflection usage

  • Log reflection operations: class loading, method invocation, script evaluation
  • Alert on attempts to load a class the allowlist does not name
  • Track how often the allowlist blocks against how often it admits
  • Look for new reflection call sites in code review
  • Use static analysis to find unsafe reflection patterns

Test the reflection fix

  • Test every allowed key and assert the right handler ran - a fix that rejects everything passes all of the rejection tests below unchanged
  • Choose a rejection payload the runtime would otherwise have accepted. On the JVM, java.lang.ProcessBuilder, java.io.FileWriter and java.net.URLClassLoader fail with NoSuchMethodException and java.lang.Runtime and sun.misc.Unsafe with IllegalAccessException whether or not your fix is present (measured on JDK 26), so a test built on them passes against no fix at all. Use a class that does construct - javax.naming.InitialContext, or a throwaway class of your own with a public no-arg constructor - and assert the rejection came from your check
  • Assert the rejection response is the same one an ordinary unknown key produces, so the endpoint is not an oracle for which classes are on the classpath
  • Test with malformed class names: ../../../Evil, not.a.real.Class (should be rejected)
  • Omit the parameter entirely, and send it empty. This is the case that turns a correct-looking allowlist into a 500: a missing parameter arrives as null, and Map.of(...).get(null) throws rather than returning null (measured on JDK 26). Assert a 400, not a 500.
  • Test the package pattern with a sibling package that shares its prefix (com.example.safeguard.Evil) and with a trailing newline appended to a legitimate name
  • If a class is loaded before being type-checked, put a class with a noisy static initializer behind an allowed-looking name and assert the initializer did not run
  • Re-scan with security scanner to confirm the issue is resolved

Common Vulnerable Patterns

A class chosen by the caller

// VULNERABLE - class name comes straight from the request
class_name = request.param('handler')
handler = instantiate_class(class_name)
// Attack: ?handler=com.thirdparty.SomeBean -> any class a dependency contributed,
//   including one whose constructor or static initializer opens a connection,
//   reads a file, or registers a shutdown hook

Why this is vulnerable: the reachable set is not the application's handlers but every class on the classpath, which includes everything any dependency brought with it. The parameter was intended to select between a few options and in fact selects from thousands, and that set grows every time a library is added.

Resolving a name is also not as inert as it looks. Loading and initializing a class are separate JVM phases, and the one-argument Class.forName(name) asks for both - so its static initializer runs as part of that call, and constructors run on instantiation. A name can therefore have an effect before any method the application meant to call is reached, which means "we only instantiate it, we never invoke anything on it" is not the reassurance it sounds like.

Instantiation alone reaches less than most write-ups suggest, and knowing where the line falls is what makes the finding triageable. Measured on JDK 26 with Class.forName(name).getDeclaredConstructor().newInstance(): java.lang.ProcessBuilder, java.io.FileWriter, java.net.URLClassLoader and java.util.Scanner all raise NoSuchMethodException, because none declares a zero-argument constructor - ProcessBuilder's String... form is a String[] parameter, not a no-arg one - while java.lang.Runtime and sun.misc.Unsafe raise IllegalAccessException on their private constructors. So the classes people reach for first cannot be built this way at all.

The ones that do construct are mostly inert at construction. javax.naming.InitialContext builds without complaint and does nothing: it initializes lazily, so getEnvironment() on the fresh object throws NoInitialContextException and no lookup has happened. It becomes a real attack when the caller also chooses the method - the next pattern on this page - at which point lookup() on that same object does reach the network (measured, a CommunicationException against port 389 of the supplied host).

Two things follow. Where the caller picks only the class, the damage is whatever the constructor or static initializer does, so the reachable surface is the application's own dependencies - where a public no-arg constructor that opens a connection or registers a hook is ordinary - rather than the JDK's own process and file types. And where the caller picks the class and a method, that bound disappears, which is why the two patterns are worth fixing together.

A name check does not fix this either: a prefix rule admits everything beneath the prefix, and an interface or base-type constraint is evaluated on a Class object that already exists, so with a plain one-argument Class.forName the initializer has run before the constraint is consulted. Loading with initialize = false closes that half and the check becomes worth having; what it still cannot bound is which classes sit under the permitted prefix. The fix is a fixed map from permitted parameter values to classes, so an unknown value is rejected rather than resolved.

A method chosen by the caller

// VULNERABLE - method name comes straight from the request
method_name = request.param('action')
invoke_method(handler, method_name)

Why this is vulnerable: the object is fixed and the operation is not, so the caller picks from everything the type exposes - including the methods it inherits, which is usually a longer list than the ones it declares. Read-only intent is not preserved: an action parameter meant to choose between two lookups can also reach a setter, a lifecycle method, or something inherited from a base class that was never considered part of this endpoint's surface.

Filtering by name is the trap here. A rule permitting anything starting with get still reaches inherited accessors that disclose internal state, and one that blocks specific names has to anticipate every method every future superclass will add. Dispatch through an explicit switch or map, where the permitted operations are written out and adding one is a deliberate act.

A script chosen by the caller

// VULNERABLE - the caller names which stored script the engine runs
script_name = request.param('rule')
engine = script_engine('javascript')
engine.eval(read_file(SCRIPT_DIR + '/' + script_name + '.js'))

Why this is vulnerable: this is the reflection shape one layer out - the caller is not writing the program, they are choosing it, and the set they choose from is whatever the path expression can reach rather than the rules the application publishes. A rule value containing ../ walks out of the script directory, and any file the process can read becomes something the engine will execute. The same holds without a filesystem: a name looked up in the engine's global bindings reaches every function the host exposed to it, not only the ones the endpoint was meant to offer.

The fix is the same as for a class name. Keep a map from the values a caller may send to the scripts they select, load by the map's value rather than by concatenating the caller's, and treat an unrecognised key as a rejected request.

Where the request carries a program rather than a name - engine.eval(request.param('code')) - the weakness is CWE-95 (Eval Injection), not this one, and the remediation is different enough to matter: there is no allowlist of names to build, because the attacker supplies the whole input. That page covers what a restricted evaluator can and cannot buy you.

Secure Patterns

// SECURE - a fixed map from request values to classes; no reflection sees user input
HANDLERS = { 'user': UserHandler, 'admin': AdminHandler, 'report': ReportHandler }

key = request.param('handler')
if key is null or key not in HANDLERS:
    return 400   // an unknown key is a bad request, not a lookup
handler = HANDLERS[key].new()

// SECURE - a fixed dispatch table for the operation, so the caller picks an
// action the endpoint publishes rather than a method the type happens to expose
ACTIONS = { 'view': handler.view, 'list': handler.list }

action = request.param('action')
if action is null or action not in ACTIONS:
    return 400
result = ACTIONS[action]()

// SECURE - if a fully-qualified name genuinely must be resolved, validate the
// string first, then load without initializing before checking the type
if not matches_whole(class_name, 'com\.example\.safe\.[A-Za-z0-9]+'):
    raise SecurityError('class name outside the permitted package')
clazz = load_class(class_name, initialize = false)
if not implements(clazz, SafeInterface):
    raise SecurityError('class does not implement the required interface')
handler = clazz.new()

Why this works: in the first two patterns the caller's value is a key, never a name that anything resolves. The reachable set is written out in the source and changing it is a deliberate edit, so it cannot grow when a dependency is added or a base class gains a method. Explicitly rejecting a null or unrecognised key matters as much as the map: a bare lookup-and-call turns a missing parameter into a crash on some map types, and a silent fallback to a default handler runs something the caller did not ask for.

The third pattern is the compromise, and its order is what makes it one. The name is checked as a string before anything resolves it; the class is then linked without being initialized, so the type check completes before any of the class's own code can run. Reversing those two steps - resolving first and checking the resulting object - gives a static initializer its chance and leaves the check reporting on something that has already happened.

Common Pitfalls

  • Checking the class after resolving it. Loading and initializing are separate phases, and the one-argument Class.forName(name) requests both - so the static initializer has already run by the time you hold the Class object, and a check on it is too late by construction. Use the three-argument form with initialize = false, and validate the name before that.
  • A package prefix read as a boundary. A permitted package admits every class beneath it, including ones a future dependency contributes to it. And a prefix check written as className.startsWith("com.example.safe") also admits com.example.safeguard.Evil, because nothing requires the next character to be the separator - the same missing-boundary bug as a path containment check without a trailing slash.
  • An allowlist of names in front of a resolver. Keeping a list of permitted method or class names and then calling the dynamic dispatcher anyway leaves two artefacts that have to agree. They stop agreeing the moment something is renamed, overloaded or inherited. Map the name to the operation instead, so there is nothing left to drift.
  • "We only instantiate it, we never call anything on it." A one-argument Class.forName initializes the class, and instantiation runs a constructor, so a name can have its effect before the application invokes a single method. It is a real bound, though - most JDK classes that construct with no arguments do nothing while doing so - and it disappears entirely the moment the caller also chooses the method.
  • Blocking the known-dangerous classes. Useful as an alert, not as a control - see the remediation section above.

Additional Resources