Tool Update – ruby-trace: A Low-Level Tracer for Ruby

We released ruby-trace back in August to coincide with my DEF CON 29 talk on it and parasitic tracing in general. Back then, it supported (c)Ruby 2.6 through 3.0. A few days ago, Ruby 3.1 was released. We have updated ruby-trace to add support for Ruby 3.1 and reorganized our test suite to validate our handling of 3.1 changes across Ruby 2.6 through 3.1.

Background

Back in this post from late 2020, I discussed some of the problems with Ruby’s tracing APIs and tracing the lower-level behaviors of Ruby in general, especially when calls start going into native code and you need to capture arguments and return values. Ruby’s Kernel#set_trace_func and TracePoint API that wraps it are Ruby’s main interface to its built-in tracing capabilities. These are focused around “events” that are emitted by the tracing implementation kicked off by the internal vm_trace() function, which gets called by the trace_* variants of Ruby’s bytecode instructions that simply call it and then jump to the non-trace bytecode instruction handler. When Ruby’s tracing is enabled, it switches bytecode execution to use the trace_* instruction handler for every instruction, ensuring that vm_trace() is called. Additionally, there are a number of EXEC_EVENT_HOOK() macro calls strewn throughout the lower-level Ruby runtime code to emit events for native events such as :c_call/:c_return and thread operations.

However, these events leave much to be desired. Even though it could arguably be modified to provide information on the bytecode instructions being executed, it only provides higher-level information focused mostly on method, class, module, and block entry or returns and doesn’t provide any means to intercept method arguments, let alone native function parameters. And it also provides very limited information on Ruby-to-native transitions, mostly just the Ruby names of native methods. Arguably, this is due to the fact that YARV bytecode is an implementation detail of the current CRuby, but it seems odd that it would not provide an extension of the TracePoint API for additional implementation-specific information, especially given how much bytecode-related functionality is thrown into the CRuby-specific RubyVM module. So I wrote my own…

ruby-trace

ruby-trace is a Frida-based tracer for CRuby, the main Ruby implementation, written in JavaScript. Targeting Ruby on Linux, it instruments Ruby/libruby to dump execution information from Ruby programs. It focuses on extracting all relevant execution information, including method, bytecode instruction, and native function arguments and return values, and additional metadata around control flow, such as branch choices and exception handling. In fact, the “simplest” part of ruby-trace is probably its general hook implementation for Ruby VM opcode handlers, for which the bytecode typically executes in a large while-goto state machine in normal builds. In addition to hooks on the Ruby send, opt_send_without_block, invokesuper, and invokebock opcodes that are primarily used to call things from Ruby code, it additionally hooks a number of other mechanisms internal to Ruby that are used to call Ruby methods and native functions, such as rb_vm_call_cfunc, vm_call_cfunc(_with_frame), rb_vm_call0, and rb_iterate0; and the various Ruby exception handling mechanisms and their native components.

To emit useful output, ruby-trace makes liberal use of the Ruby inspect method to stringify objects, with alternate handling for the critical regions in the VM where Ruby functions cannot safely be dispatched. It will additionally disassemble bytecode objects (e.g. instruction sequence (“ISEQ”)), like methods and blocks, when they are defined (and occasionally generated). It also provides useful human-readable representations for a number of internal values and flags.

Overall, ruby-trace, very deeply instruments the Ruby VM, covering every single one of the 110 Ruby VM opcodes between Ruby 2.6 and 3.1, including several that Ruby itself is incapable of emitting, including vestigial opcodes that likely never actually worked at all.

Design

ruby-trace deeply integrates with Ruby/libruby, not only in the form of its hooks placed throughout, but also in its use of Ruby symbols and C API functions to work with, analyze, and invoke methods on Ruby objects. ruby-trace bundles a large number of Ruby struct definitions for each version and uses a shared interface to abstract them, with version-specific overrides where necessary, enabling it to directly access fields from internal structs and extract call and opcode metadata. The main benefit of this approach is that it is fairly simple to update ruby-trace to support additional versions of Ruby, and doing so does not require maintaining an out-of-tree patchset or unmaintainable fork of Ruby to gain advanced trace output.

Because YARV instruction handlers are not generally compiled as functions with proper preludes and returns, but instead are labeled goto blocks in a state machine, it is not really possible to hook their return, because they generally don’t return at all. Instead, to get opcode results, which are typically placed on the top of the Ruby VM stack, ruby-trace abuses the fact that it hooks all opcode handler entrypoints to unravel the state machine from the inside and place callback functions to pull the returned values before the next instruction is executed.

In general, other than its liberal use of inspect, ruby-trace attempts to avoid performing actions that will cause significant side-effects on execution. As it cannot always glean the full context of a given instruction being executed, ruby-trace often reimplements the logic within opcode handlers to precompute certain information about the instruction being executed based on its register and stack arguments. Additionally, due to the fact that ruby-trace must stringify Ruby objects during critical regions within the VM where Ruby’s method dispatch cannot be used, it implements its own form of Ruby method dispatch parallel to the one internal to Ruby by assembling maps for all inspect and to_s methods by hooking rb_define_method and rb_define_alias. Due to these techniques, ruby-trace, in some sense, acts like a second YARV VM injected into Ruby. Alas, much of the code within ruby-trace exists solely for these kinds of reasons, to prevent Ruby’s own code from crashing itself because Ruby does not like calling Ruby methods during arbitrary junctures within the VM’s execution; much of the work on ruby-trace is focused on squashing instances of memory corruption Ruby constantly does to itself. And trust me, it’s turtles all the way down and all the way up.

Usage

ruby-trace can be installed with sudo npm install -g ruby-trace. To use ruby-trace, add and enable a TracePoint object to a script to trace as shown below, and run the script with ruby-trace -- prepended to the ruby command used to run the script.

demo.rb
require 'bigdecimal'

def trace
  t = TracePoint.new(:call) { |tp| }
  t.enable
  yield
ensure
  t.disable
end

def foo(x)
  case x
  when 'hello'
    'world'
  when 1
    "num"
  when 2147483648
    "bignum"
  when 3.0
    "float"
  when true
    "bool"
  when nil
    "nil"
  when "foo"
    "string"
  when :foo
    "symbol"
  else
    "not found: " + x.to_s
  end
end

trace {
  a = [foo('hello'), foo(1), foo(2.0 + 1.0), foo(BigDecimal("3.0")), foo(:foo), foo("foo"), foo(2147483648)]

  class Symbol
    undef ===
    def ===(*args)
      true
    end
  end
  b = [foo('hello'), foo(1), foo(2.0 + 1.0), foo(BigDecimal("3.0")), foo(:foo), foo("foo"), foo(2147483648)]
  puts [a, b].inspect
}
ruby-trace -- ruby demo.rb

Call us before you need us.

Our experts will help you.

Get in touch