Metaprogramming is code that writes code: methods that don’t exist until they’re called, classes assembled at runtime from data instead of a class body, entire chunks of logic generated instead of typed out by hand. Ruby leans into this harder than most languages, and it’s worth knowing both because it makes real Ruby codebases (Rails included) legible, and because the same techniques that make metaprogramming powerful are exactly what makes eval-style code a favorite RCE vector when it’s used carelessly.
Ruby’s dynamic typing and open classes are what make all of this possible: methods and classes aren’t fixed at compile time, so there’s nothing stopping you from defining or redefining them while the program is already running.
Techniques for writing code that writes code#
method_missing#
method_missing fires whenever you call a method that doesn’t exist on an object. Override it, and you can intercept those calls and decide what happens instead of letting Ruby raise NoMethodError.
The textbook mistake is using method_missing to hardcode a single method name, which is really just a convoluted way of writing a regular method. method_missing earns its keep specifically when you’re handling a whole family of method names that aren’t known until runtime:
class Person
attr_accessor :name, :age
def initialize(name, age)
@name = name
@age = age
end
def method_missing(method_name, *args)
if method_name.to_s.start_with?("shout_")
attribute = method_name.to_s.sub("shout_", "")
send(attribute).to_s.upcase
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
method_name.to_s.start_with?("shout_") || super
end
end
person = Person.new("Alice", 30)
person.shout_name # => "ALICE"
person.shout_age # => "30"That single method_missing implementation handles shout_name, shout_age, and any other shout_<attribute> call without defining each one by hand. Note the respond_to_missing? override alongside it; skip that and person.respond_to?(:shout_name) incorrectly returns false, which breaks introspection and anything downstream that relies on it (method(), respond_to? checks in other libraries, and so on). It’s easy to forget and it’s the single most common bug in real-world method_missing code.
define_method#
define_method defines a method programmatically from a block, at the time the class body runs rather than being typed out as a fixed method definition:
class BankAccount
attr_accessor :balance
def initialize(balance)
@balance = balance
end
define_method :deposit do |amount|
@balance += amount
end
endFunctionally, account.deposit(amount) behaves exactly like a normal method call. The difference shows up when you need to generate several similar methods from a list, a pattern Rails itself leans on constantly (attribute accessors, association methods, and so on are largely define_method-generated, not hand-written).
define_method is usually the better choice over method_missing when you already know the method names in advance: it’s faster (no interception overhead on every call), and it doesn’t silently break respond_to? the way an incomplete method_missing implementation does.
Dynamically defining classes and modules#
Ruby lets you build entire classes at runtime instead of writing a fixed class block, which is useful when the shape of what you need to define depends on external data:
class_data = File.read("class_data.txt") # one command per line, e.g. "bark\nfetch\nsit"
class_name = "TrainedDog"
Object.const_set(class_name, Class.new)
new_class = Object.const_get(class_name)
class_data.split.each do |command|
new_class.send(:define_method, command) do
"#{command}!"
end
end
TrainedDog.new.bark # => "bark!"
TrainedDog.new.fetch # => "fetch!"Class.new creates an anonymous class, Object.const_set gives it a real constant name so it behaves like any other top-level class, and the loop generates one method per line of input data. Change the contents of class_data.txt and the class’s entire method set changes with it, no code edit required.
Executing code dynamically with eval#
eval runs a string as Ruby code. It’s the most powerful metaprogramming tool in the language, and also the one most likely to hand an attacker remote code execution if you’re not careful with it:
user_input = "puts 'Hello, world!'"
eval(user_input)Here’s the part worth being precise about: “validate and sanitize the input first” is not a real defense against eval. Ruby code isn’t a fixed grammar you can safely filter with a blocklist or regex; a sufficiently motivated attacker will find a way to smuggle arbitrary code past any sanitization scheme you write, the same way escaping quotes doesn’t actually make string-concatenated SQL safe. If eval is running on attacker-influenced input, that’s a code execution vulnerability, full stop, not a hardening opportunity.
If you actually need to run dynamic, user-supplied logic, the safer options are: don’t use eval at all and build a small allowlist-based DSL that only exposes the specific operations you intend to support, or run the code in a genuinely sandboxed, resource-constrained subprocess with no access to the filesystem, network, or credentials it shouldn’t have. Ruby’s $SAFE levels and taint checking, which used to be the standard advice here, are gone now precisely because they never provided the isolation guarantees people assumed: $SAFE lost its special behavior in Ruby 3.0, and the taint/trust methods (Object#taint, #untaint, and friends) were removed outright in Ruby 3.2. There’s no built-in Ruby sandbox you can trust for untrusted code today; that’s OS-level isolation (containers, a separate unprivileged process) or nothing. Treat eval on anything user-controlled as an RCE bug, not a feature to defend.
Conclusion#
method_missing, define_method, runtime class construction, and eval all do the same basic thing at different levels of risk: they let a Ruby program write and reshape its own code while it’s running. The first three are genuinely useful for cutting repetition and building flexible APIs, with define_method usually the safer, faster default over method_missing once you know what you’re generating. The last one, eval on anything you don’t fully control, is worth treating as a vulnerability class rather than a convenience.