skip to content

Code-Injection Sinks

String eval, send or const_get with a user-chosen name, Kernel#open on a user path and ERB on an untrusted template each let input run as code. Interviewers ask you to spot and allowlist them.

on this pageshow

explore

questions

5

In Ruby, why are eval and the string forms of instance_eval and class_eval unsafe on request data, and what replaces them?

level: juniorimportance: must knowfreq 62%

answer

  1. the string is parsed as Ruby source
  2. full process privileges, no quoting mode
  3. $SAFE inert since 3.0, taint gone in 3.2
  4. filtering input is not a defence
  5. lookup tables, parsers, blocks, define_method

basics

~20 s

Each of them parses its string argument as Ruby source and runs it with the process's full privileges, so request data that shapes the string can run any code. Replace them with lookup tables, real parsers and block-based metaprogramming.

solid answer

~40 s

`Kernel#eval`, `Binding#eval` and the string forms of `instance_eval` and `class_eval`/`module_eval` hand their argument to the parser and execute the result, so `eval("row.#{column}")` lets a request that sends `"id; File.read('/etc/passwd')"` read files, call `exit!` or reopen classes. Ruby has no sandbox for this: `$SAFE` has been an ordinary global since 3.0 and the taint methods were removed in 3.2. Filtering characters fails because Ruby has too many ways to spell a call. The fix is never to build code from data: look values up in a frozen hash, parse numbers with `Integer()` and structured data with `JSON.parse`, and pass blocks to `instance_exec`, `class_eval` or `define_method` when you genuinely need metaprogramming.

code

ruby · 14 lines
ruby
# Unsafe: the request chooses Ruby source
def cell(row, column)
  eval("row.#{column}") # column = "id; File.read('/etc/passwd')"
end

# Safe: the request only chooses a key
CELLS = {
  "name"  => ->(row) { row.name },
  "total" => ->(row) { row.total }
}.freeze

def cell(row, column)
  CELLS.fetch(column) { raise ArgumentError, "unknown column" }.call(row)
end

go deeper

for a junior

Recall that eval and the string forms of instance_eval and class_eval run text as Ruby code, and that request data must never reach them.

for a middle

Explain why filtering fails and $SAFE no longer exists, then show the replacements: a frozen hash lookup, Integer() or JSON.parse, and block-based define_method.

for a senior

In review, trace where each evaluated string's parts come from, including database rows and tenant uploads, and replace the design rather than adding a sanitizer.

for a principal

Argue for a team rule that string evaluation lives only in tooling, enforced by review and linting, so runtime code builds nothing from data.

## What a string sink is A **code-injection sink** is a call that takes a string and treats it as **Ruby source**. Whatever text reaches it is parsed by the interpreter and executed with every privilege the running process holds: it can read files and environment variables, open sockets, redefine constants, reopen classes or call `exit!`. There is no quoting mode and no "data only" flag. If request data can shape the string, the request chooses the code. The entry points that accept a string: | API | String form (a sink) | Replacement | |---|---|---| | `Kernel#eval` | `eval(str)` | redesign so no code is built from data | | `Binding#eval` | `some_binding.eval(str)` | same as `eval` | | `BasicObject#instance_eval` | `obj.instance_eval(str)` | `obj.instance_exec(value) { ... }` | | `Module#class_eval` / `module_eval` | `Klass.class_eval(str)` | `Klass.class_eval { ... }` or `define_method` | The **block forms are not sinks**. The code inside a block was written by the developer and parsed when the file loaded; request data can only reach it as an ordinary value, and a value is never re-parsed. ## Two worked failures - **A display column.** A report screen lets the user choose which attribute of each row to show, and the code runs `eval("row.#{column}")`. The intended input is `"total"`. An attacker sends `"id; File.read('/etc/passwd')"`. The string becomes two statements, `eval` runs both, and it returns the value of the last one, so the page renders the file. - **Generated methods.** Code that builds helpers from configured names with `Report.class_eval("def #{name}_label; LABELS[#{name.inspect}]; end")` looks careful because the second interpolation is quoted with `inspect`. The first one is not: `name = "a; end; File.delete('x'); def b"` yields a syntactically valid chunk that deletes a file while the class is being extended. Both bugs share a root cause: a **name** that should have been data was spliced into **source**. ## Why filtering and sandboxing do not work - **There is no sandbox.** Older Ruby tracked tainted strings and let `$SAFE` refuse them. Since Ruby 3.0, `$SAFE` is an ordinary global with no special behaviour, and Ruby 3.2 removed `Kernel#taint`, `untaint` and `tainted?`. Advice that relies on them is obsolete. - **Denylists lose.** Ruby offers many spellings for the same effect: `send`, `__send__`, `public_send`, `Object.const_get`, `method(:name).call`, string escapes that rebuild a blocked word, and constants reached through `::`. Ruby's own security guide says not to attempt filtering input before passing it to `eval`. - **Catching errors is not validation.** `rescue SyntaxError` only catches text that fails to parse; a valid malicious string parses fine and runs. `SyntaxError` is a `ScriptError`, so a bare `rescue` (which catches `StandardError`) does not even see it. - **The attack surface is the whole language**, plus every gem loaded into the process. ## What to write instead 1. **Look values up instead of computing them.** Map permitted inputs to code you wrote: a frozen `Hash` from column names to lambdas, read with `Hash#fetch` so an unknown key raises `KeyError` (or runs your fallback block) instead of falling through. 2. **Parse data with a parser.** Numbers come from `Integer()` or `Float()`, which raise `ArgumentError` on junk; structured data comes from `JSON.parse`. None of them can execute anything. 3. **Pass blocks, not strings, to metaprogramming.** Use `instance_exec`, `class_eval` with a block, or `define_method(:"#{name}_label") { ... }`. The name becomes a symbol and never reaches the parser. Still restrict the names to a known list: each method `define_method` creates makes an immortal symbol, so unbounded user-chosen names grow memory. 4. **Keep string evaluation for tooling.** REPLs such as IRB, code generators run at build time and template engines over developer-owned files are the legitimate users; in all of them the developer, not a request, wrote the text. ## Spotting it in review - Any `eval`, `instance_eval`, `class_eval` or `module_eval` whose argument is a string, especially one with `#{}` interpolation. - Strings assembled from request parameters, headers, cookies, rows an end user can edit, or files a tenant uploads. - Helpers that "sanitize" input right before an evaluation call: the sanitizer is a sign the author knew the value was dangerous and chose the wrong fix. - The same root cause appears with `send`, `public_send` and `const_get` on request names and with `ERB.new` on user templates; each is fixed the same way, by choosing from a list you control.

  • Does wrapping eval of request data in begin/rescue SyntaxError make it safe?
    No. `rescue SyntaxError` only catches text that fails to parse; a well-formed malicious string parses and runs before any rescue matters. `SyntaxError` also descends from `ScriptError`, not `StandardError`, so a bare `rescue` would not catch it at all.
  • Is instance_exec with a block safe when the block's argument comes from the request?
    Safe from code injection, yes: the block was parsed from your source file, and the request value arrives as an ordinary object that is never re-parsed. What the block then does with that value is normal code review; it could still hand the value to another sink such as `send` or `const_get`.
  • Where is string evaluation legitimately used in Ruby?
    Where the developer wrote the text: REPLs like IRB, ERB compiling templates that live in the repository, and code generators run at build time. The rule is about who authored the string, not about the method.

Evaluating request text is like letting a customer write directly on the order ticket the cook follows word for word: they can order a dish or write 'empty the till'. A numbered menu, where the customer can only point, is the allowlist.

saying these in an interview costs you the question

  • Setting $SAFE to 1 or higher sandboxes an eval of user input.
  • Stripping semicolons and backticks makes eval of request data safe.
  • instance_eval with a string is safe because it only affects one object.
  • Wrapping eval in rescue SyntaxError stops malicious input from running.
  • define_method with a block is as injectable as string class_eval.
open as a page

In Ruby 4.0, what changed about Kernel#open with a leading pipe, and why still prefer File.open or a checked URI.open for request data?

level: middleimportance: must knowfreq 48%

basics

~20 s

Ruby 4.0 removed process creation from Kernel#open and the IO class methods, so "|cmd" is now just a file name. A request path can still escape its directory, and URI.open still fetches any URL or reads a local file.

open as a page

In Ruby, what can Formats.const_get(params[:format]).new instantiate when the name comes from the request, and how do you restrict it?

level: middleimportance: should knowfreq 34%

basics

~20 s

Any constant reachable from Formats: with the default inherit flag a module lookup also searches Object, so "File" returns ::File, and a leading "::" restarts at Object even with inherit false. Map allowed names to classes in a frozen hash instead.

open as a page

In a Ruby report builder, why is ERB.new(params[:template]).result dangerous, and how should user-authored report templates be rendered instead?

level: middleimportance: should knowfreq 30%

basics

~20 s

ERB compiles a template into Ruby source and ERB#result runs it with eval, so a user-written template can execute any code the app can. Render user templates with placeholder substitution from a fixed hash, or a logic-less template language.

open as a page

In a Ruby report builder, why is rows.sort_by { |r| r.public_send(params[:sort]) } still unsafe, and how do you allowlist it?

level: seniorimportance: should knowfreq 38%

basics

~20 s

public_send only refuses private methods; every public method stays reachable, including send, instance_eval, freeze and the model's own mutators. Map the request value through a fixed hash of permitted sort keys and dispatch on the symbol you chose.

open as a page