Posted almost 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

By refactoring problematic code and creating automated tests, makandra can vastly improve the maintainability of your Rails application.

Author of this card:

Avatar
Dominik Schöler
Last edit:
about 1 month 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