skip to content

In Ruby 4.0, can calling to_sym on untrusted input such as a delivery status parameter leak memory, and how would you guard against it?

level: seniorimportance: should knowfreq 40%

answer

  1. static versus dynamic symbols
  2. collectable since Ruby 2.2
  3. pinned once used as an internal ID
  4. Symbol.all_symbols.size shows growth
  5. allowlist and compare with name

basics

~20 s

Mostly not since Ruby 2.2: symbols created at runtime by to_sym are garbage-collected once unreferenced. Symbols from source code, and dynamic ones pinned as method names, live for the whole process, so map untrusted input through an allowlist instead.

solid answer

~50 s

Ruby has **static** symbols, those written in source code and the names the interpreter registers, which live for the life of the process, and **dynamic** symbols, created at runtime by `String#to_sym`, `String#intern` or an interpolated `:"..."` literal. Since Ruby 2.2 most dynamic symbols are ordinary objects that the GC collects when nothing references them; before that every `status_param.to_sym` on request data leaked, which made it a denial-of-service vector. Two risks remain. A dynamic symbol is made **permanent** once Ruby needs it as an internal identifier, for example when `define_method` uses it as a method name. And symbols held by long-lived structures stay alive like any object. The guard is to not create symbols from input at all: keep `STATUSES = %i[pending in_transit delivered].freeze` and pick with `STATUSES.find { |s| s.name == input }`, which returns `nil` for unknown text. `Symbol.all_symbols.size` shows whether the table is growing.

code

ruby · 10 lines
ruby
class Parcel
  STATUSES = %i[pending in_transit out_for_delivery delivered returned].freeze

  def self.parse_status(input)
    STATUSES.find { |status| status.name == input }
  end
end

Parcel.parse_status("in_transit") # => :in_transit
Parcel.parse_status("x" * 64)     # => nil, and no new Symbol is created

go deeper

for a junior

Remember that turning untrusted text into symbols used to leak memory, and that checking input against a fixed list is the safe habit.

for a middle

Explain static versus dynamic symbols, what changed in Ruby 2.2, and how Symbol.all_symbols.size reveals growth.

for a senior

Identify the residual risks in production code: pinning through define_method and similar metaprogramming, and symbols held by long-lived caches or registries.

for a principal

Make input handling independent of GC behaviour: require allowlists at trust boundaries so no future runtime change can reopen the leak.

## Why this used to be a classic vulnerability Symbols are interned: Ruby keeps one object per name in a global **symbol table**. For many years that table only grew. Any code that turned request data into symbols - a status parameter passed to `to_sym`, keys symbolized from a parsed payload, a header name interned for lookup - let a client create an unbounded number of permanent objects by sending unique values. Memory climbed until the process died. **Ruby 2.2** changed this: most symbols returned by `String#to_sym` and `String#intern` became garbage-collectable. ## Static and dynamic symbols | Kind | Created by | Collected? | |---|---|---| | **static** | symbol literals in source (`:delivered`), names the interpreter registers for methods, constants and variables | never - they live as long as the process | | **dynamic** | `String#to_sym`, `String#intern`, interpolated literals such as `:"status_#{code}"` | yes, when nothing references them | | **pinned dynamic** | a dynamic symbol that Ruby later needs as an internal identifier | no - it is made permanent | `String#to_sym` first looks the name up; if a static symbol with that name exists it simply returns it, so converting `"delivered"` never creates anything new. Only unseen names produce dynamic symbols. ## When a dynamic symbol becomes permanent CRuby turns a dynamic symbol into a permanent entry when it has to assign it an internal ID - the identifier the VM uses for method tables and variable names. Defining a method with that name is the clearest case: ```ruby name = "track_#{input}".to_sym Parcel.define_method(name) { status } # the name is now permanent ``` So the memory question and the metaprogramming question meet: code that turns user input into **method names** keeps those names forever, and has worse problems than memory. Any attacker-chosen name that becomes a method, attribute or constant is a design defect in its own right. ## Symbols kept alive by your own code Collectable does not mean collected. A dynamic symbol lives as long as something references it: - a class-level registry or memoized lookup that stores every status ever seen; - a long-lived cache keyed by symbols derived from input; - log or metrics buffers that accumulate symbol values. These are ordinary leaks - the same as storing unique strings - but they are easy to miss because symbols look like constants. ## Guarding a delivery-status parameter The robust approach avoids creating symbols from input at all: 1. Declare the allowed values once: `STATUSES = %i[pending in_transit out_for_delivery delivered returned].freeze`. 2. Match the incoming text against their names with `STATUSES.find { |s| s.name == input }`. `Symbol#name` returns a cached frozen string, so the comparison allocates nothing and unknown input yields `nil`. 3. Reject or report `nil` at the boundary instead of passing the raw value deeper. 4. Keep calls such as `to_sym` for text that has already passed that check, or for data your own code produced. ## Checking for growth `Symbol.all_symbols` returns an array of every symbol currently in the table, so `Symbol.all_symbols.size` before and after a suspicious workload (with a `GC.start` in between) shows whether symbols are accumulating. On Ruby 2.2 and later, a loop that creates 100,000 throwaway symbols with `to_sym` and drops them returns to roughly its starting count after a GC, while the same number of names passed to `define_method` stays. ## What a senior answer includes - The history: pre-2.2 symbol tables only grew, which is why "never `to_sym` user input" became folklore. - The current rule: dynamic symbols are collectable; static and pinned ones are not. - The residual risks: pinning through metaprogramming, and your own long-lived references. - The fix that does not depend on GC details: an allowlist, compared by `name`.

  • In Ruby, does "delivered".to_sym create a new dynamic symbol if the code already contains the literal :delivered?
    No. `String#to_sym` looks the name up in the symbol table first and returns the existing symbol, so it returns the same static `:delivered` object as the literal. Only names never seen before produce a new dynamic symbol, which is why well-known values are cheap to convert and only unbounded input is a concern.
  • In Ruby 4.0, why is define_method with a name built from user input still a memory problem?
    Defining a method requires an internal ID for its name, and CRuby makes a dynamic symbol permanent when it assigns one. Every distinct name therefore stays in the symbol table for the life of the process, as does the method itself. The bigger defect is design: attacker-chosen method names should never exist, so map input to a fixed set of behaviours instead.

saying these in an interview costs you the question

  • Every to_sym call in Ruby 4.0 leaks memory forever, as it did before 2.2.
  • Because symbols are garbage-collected now, creating them from any input is always safe.
  • Symbol literals written in source code are collected when no longer referenced.
  • Symbol.all_symbols only lists symbols written in source code.
  • A symbol used as a define_method name is collected once the method is removed.