Posted over 2 years ago. Visible to the public.

Ruby bug: Symbolized Strings Break Keyword Arguments in Ruby 2.2

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.

Copy
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

Does your version of Ruby on Rails still receive security updates?
Rails LTS provides security patches for old versions of Ruby on Rails (3.2 and 2.3).

Author of this card:

Avatar
Dominik Schöler
Last edit:
10 months ago
by Arne Hartherz
About this deck:
We are makandra and do test-driven, agile Ruby on Rails software development.
License for source code
Posted by Dominik Schöler to makandropedia