Ruby bug: Symbolized Strings Break Keyword Arguments in Ruby 2.2

Updated . Posted . Visible to the public.

TL;DR Under certain circumstances, dynamically defined symbols may break keyword arguments in Ruby 2.2. This was fixed in Ruby 2.2.3 and 2.3.

Specifically, when

  • there is a method with several keyword arguments and a double-splat argument (e.g. def m(foo: 'bar, option: 'will be lost', **further_options))
  • there is a dynamically created Symbol (e.g. 'culprit'.to_sym) that is created before the method is parsed
  • the method gets called with both the option and a culprit keyword argument

then the option keyword argument will be nil inside of #m.

Affected Ruby Versions

  • Affected version: 2.2.0, 2.2.1, 2.2.2
  • Unaffected versions: 1.x, 2.0, 2.1, 2.2.3+, 2.3+

How to Expose it

The following code exposes the bug. Save it to a file, make sure you have Ruby 2.2 and run it.

test_symbol = '__test'

# The bug only occurs when a symbol used in a keyword argument is dynamically
# added to the Ruby symbols table *before* Ruby first sees the keyword argument.
existing = Symbol.all_symbols.map(&:to_s).grep('__test')
raise "Symbol #{test_symbol} already exists in symbol table!" if existing.any?

'__test'.to_sym # breaks it
# :__test # does not break it

# GC.start # fixes it

# Why #eval?
# Without, Ruby would parse the symbols in this code into its symbol table
# before running the file, which prevents the bug.
eval <<-RUBY
  $hash = { __test: '__test', lost: 'lost', q: 'q' }

  def _report(name, value)
    puts name.to_s << ': ' << (value ? 'ok' : 'broken')
  end

  # Confirmed broken when:
  # - `lost` is the second keyword argument Oo
  # - there is a double-splat argument
  def vulnerable_method_1(p: 'p', lost: 'lost', **options)
    _report(__method__, lost)
  end

  def vulnerable_method_2(p: 'p', lost: 'lost', q: 'q', **options)
    _report(__method__, lost)
  end

  def immune_method_1(lost: 'lost', p: 'p', **options)
    _report(__method__, lost)
  end

  def immune_method_2(q: 'q', lost: 'lost', __test: '__test')
    _report(__method__, lost)
  end

  def immune_method_3(lost: 'lost', **options)
    _report(__method__, lost)
  end
RUBY

# Exposure #####################################################################

puts '', 'Broken when calling with a hash'
vulnerable_method_1($hash)
vulnerable_method_2($hash)
immune_method_1($hash)
immune_method_2($hash)
immune_method_3($hash)

puts '', 'Double splat (**) has no influence:'
vulnerable_method_1(**$hash)
vulnerable_method_2(**$hash)
immune_method_1(**$hash)
immune_method_2(**$hash)
immune_method_3(**$hash)

puts '', 'Hash order does not matter:'
inversed_hash = Hash[$hash.to_a.reverse]
vulnerable_method_1(inversed_hash)
vulnerable_method_2(inversed_hash)
immune_method_1(inversed_hash)
immune_method_2(inversed_hash)
immune_method_3(inversed_hash)

References

Profile picture of Dominik Schöler
Dominik Schöler
Last edit
Arne Hartherz
License
Source code in this card is licensed under the MIT License.
Posted by Dominik Schöler to makandra dev (2015-04-23 12:24)