Read more

Ruby bug: Symbolized Strings Break Keyword Arguments in Ruby 2.2

Dominik Schöler
April 23, 2015Software engineer at makandra GmbH

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.

Illustration online protection

Rails Long Term Support

Rails LTS provides security patches for old versions of Ruby on Rails (2.3, 3.2, 4.2 and 5.2)

  • Prevents you from data breaches and liability risks
  • Upgrade at your own pace
  • Works with modern Rubies
Read more Show archive.org snapshot

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

Dominik Schöler
April 23, 2015Software engineer at makandra GmbH
Posted by Dominik Schöler to makandra dev (2015-04-23 14:24)