Skip to content

objtracer is a file-static VALUE never registered with the GC — StackProf.stop can dereference a recycled slot #245

Description

@jeremy

objtracer is a file-static VALUE that is never registered with the GC — StackProf.stop can dereference a recycled object slot

Repo: tmm1/stackprof · Affected: 0.2.26, 0.2.28, and main today · Latent, see §Reachability

1. Summary

stackprof.c keeps the object-mode TracePoint in a file-static VALUE, objtracer. It is
written in StackProf.start(mode: :object) and read back in a later StackProf.stop, but it is
never passed to rb_global_variable and is named in no mark function, so the GC cannot see it.
Its two siblings in the same file are registered (_stackprof.empty_string,
_stackprof.fake_frame_names[i]) — registration is per-slot, so that does not cover this one.

While the tracepoint is enabled the VM's global hook list holds it, and that reference both
marks and pins it — so on stackprof's own startstop path nothing goes wrong. This is a
latent defect, not a reachable one.
It becomes live only if something else disables that
tracepoint while stackprof still believes it is running: the hook list drops its reference,
nothing else has one, ordinary GC frees the object, and the later StackProf.stop dereferences
the recycled slot. Measured outcomes, all deterministic (3/3) on 0.2.26 and 0.2.28:

what recycles the slot result of StackProf.stop
a String TypeError: wrong argument type String (expected tracepoint)
nothing (slot left T_NONE) SIGSEGV at 0x0 inside the stop CFUNC
another TracePoint returns true and silently disables somebody else's tracepoint

The third row is the one worth the fix: no exception, no crash, just another library's
instrumentation quietly switched off.

Separately, and even on the ordinary path: after StackProf.stop in object mode the tracepoint is
disabled and unreferenced, so objtracer holds a freed VALUE from that moment until the next
StackProf.start(mode: :object) overwrites it. Nothing reads it in between, so it is inert — but
the static is routinely left dangling.

2. Reproduction

Standalone, no gems beyond stackprof. Ruby 4.0.6, but the mechanism is plain unreachability and
does not depend on compaction or on a particular GC.

require "stackprof"
require "fiddle"

def slot_type(addr)                 # low 5 bits of the object slot's flags word
  Fiddle::Pointer.new(addr)[0, 8].unpack1("Q<") & 0x1f
end

StackProf.start(mode: :object, interval: 1)
2_000.times { Object.new }          # make the tracepoint actually fire

tp   = ObjectSpace.each_object(TracePoint).find(&:enabled?)
addr = ObjectSpace.dump(tp)[/"address":"([^"]+)"/, 1].to_i(16)
tp   = nil                          # see note (a)

# (b) A third party disables every TracePoint. This is the entire precondition.
ObjectSpace.each_object(TracePoint) { |t| t.disable }

# Ordinary GC only: no GC.stress, no GC.start, no GC.compact.
$filler = []
200_000.times { $filler << +("Z" * 100) }        # see note (c)

puts "tracepoints left: #{ObjectSpace.each_object(TracePoint).count}"  # => 0
puts format("slot type now:    0x%02x", slot_type(addr))               # => 0x05  (T_STRING)

StackProf.stop
# => TypeError: wrong argument type String (expected tracepoint)

Two non-obvious steps, because they are the difference between reproducing and not:

  • (a) tp = nil. A TracePoint held in a live local is conservatively pinned by the machine
    stack scan and will never be collected, so the bug cannot appear. Measured: keeping tp alive
    gives tracepoints 1 -> 1 after the same 200,000 allocations and StackProf.stop returns
    true.
  • (c) the filler is 100 bytes. A vacated object slot is only reused by objects from the same GC
    size pool. Here ObjectSpace.dump(tp)["slot_size"] is 160, and a 100-byte String has
    slot_size 160 too. A 10-byte filler lands in a different pool and the slot stays T_NONE.
  • (b) the external disable is required, and it is exactly what keeps this latent. While the
    tracepoint is enabled it is pinned: measured with 200 fresh witness Strings under
    GC.verify_compaction_references(expand_heap: true, toward: :empty)200/200 witnesses
    relocated while the tracepoint's address did not change
    , on both pins.

Two variants of the last two steps, each also 3/3:

# Slot left vacant -> hard crash instead of a TypeError.
4.times { GC.start }                # instead of the $filler loop
StackProf.stop                      # => [BUG] Segmentation fault at 0x0000000000000000
                                    #    c:0003 CFUNC :stop

# Slot recycled by another TracePoint -> silent hijack, no exception at all.
$filler = []
200_000.times { $filler << TracePoint.new(:line) {} }
victim = $filler.find { |t| ObjectSpace.dump(t)[/"address":"([^"]+)"/, 1].to_i(16) == addr }
victim.enable
StackProf.stop                      # => true
victim.enabled?                     # => false   <- stackprof disabled someone else's tracepoint

Rates. There is nothing probabilistic to report: once the tracepoint has been disabled
externally, collection is certain. 3/3 for each of the three outcomes on 0.2.26 and 0.2.28, with no
amplifier — no GC.stress, and the String-recycling path uses no explicit GC.start either,
only allocation.

Controls (same script, flags): with no external disable, and with an external disable but no
allocation afterwards, StackProf.stop returns normally — 3/3 each, both pins. So it is the
collection, not the disable, that breaks it.

3. Cause

ext/stackprof/stackprof.c (line numbers from 0.2.28; 0.2.26 is 140/199/248):

168:  static VALUE sym_gc_samples, objtracer;
      ...
227:      objtracer = rb_tracepoint_new(Qnil, RUBY_INTERNAL_EVENT_NEWOBJ, stackprof_newobj_handler, 0);
228:      rb_tracepoint_enable(objtracer);
      ...
288:      rb_tracepoint_disable(objtracer);        /* read back in a LATER call */

and in Init_stackprof, where three neighbours are registered and this one is not
(0.2.28 line numbers; main is +1 on each):

994:      rb_global_variable(&gc_hook);
      ...
1007:     rb_global_variable(&_stackprof.empty_string);
1011:         rb_global_variable(&_stackprof.fake_frame_names[i]);

4. Affected versions

version rb_global_variable(&objtracer) verdict
0.2.26 absent affected, reproduced 3/3
0.2.28 absent affected, reproduced 3/3
main (ext/stackprof/stackprof.c, fetched today) absent — decl :168, write :227, read :288 affected (code reading)

main differs from 0.2.28 only in stackprof_record_sample_for_stack, which now takes the frame
and line buffers as parameters; the three objtracer lines and the Init_stackprof registrations
are otherwise unchanged. Not version-dependent in any interesting way — the static has never been
registered.

5. Suggested fix

One line, next to the registration already there for gc_hook, in Init_stackprof:

     rb_global_variable(&gc_hook);
+    rb_global_variable(&objtracer);

Ran it. Patched /src/stackprof-{0.2.26,0.2.28}, rebuilt, and re-ran the same reproducer with
the same flags in the same step as the build: the TracePoint is no longer collectable, the
precondition cannot fire, and StackProf.stop is normal — 3/3 on both, while the unpatched build
from the identical source tree fails 3/3.

If you would rather not keep the last tracepoint alive forever, the alternative is
objtracer = Qnil; after rb_tracepoint_disable(objtracer) in stackprof_stop plus a
RTEST(objtracer) guard — but that leaves the window between start and an external disable
still unguarded, so the rb_global_variable form is the one that actually closes it.

6. Reachability check — why this is filed as latent

The query: rg -n 'objtracer' ext/stackprof/stackprof.c — three hits, the declaration, the write
in stackprof_start, and the single read in stackprof_stop. That read is guarded by
_stackprof.running and _stackprof.mode == sym_object, and for the whole of that window the
tracepoint is enabled, hence held by vm->global_hooks and pinned (measured above, 200/200
witnesses). Walking stackprof's own entry points:

  • StackProf.stop twice → the second call early-returns on !running.
  • StackProf.results → calls stackprof_stop only when running.
  • StackProf.start(mode: :object) twice → the second call reassigns objtracer.
  • StackProf.start(mode: :wall|:cpu|:custom) after an object-mode stop → mode is no longer
    sym_object, so objtracer is never read.

So no sequence of stackprof's own API frees the object before the read. The trigger requires a
third party to call TracePoint#disable on stackprof's tracepoint while stackprof still
believes it is running.

Searched for such a caller and did not find one: gh search code 'ObjectSpace.each_object(TracePoint)' (30 results) is entirely ruby/mspec's
lib/mspec/runner/actions/leakchecker.rb and its vendored copies, plus ruby's own
test/ruby/test_settracefunc.rb — and mspec only reports tracepoints left enabled, it does not
disable them. grep -rl 'each_object(TracePoint)' over the installed gem corpus used for this
audit: 0 hits.

Filed publicly as a latent defect for that reason: no untrusted input reaches it and I found no
in-the-wild caller that triggers it. It is a real dangling VALUE all the same, the fix is one
line, and the silent-tracepoint-hijack outcome would be very hard to diagnose if it ever did fire.

7. Environment

Printed from the running process, not from memory:

ruby 4.0.6 (2026-07-14 revision 03b6d3f889) +PRISM [aarch64-linux]
stackprof 0.2.26 -> /usr/local/bundle/gems/stackprof-0.2.26/lib/stackprof/stackprof.so
stackprof 0.2.28 -> /usr/local/bundle/gems/stackprof-0.2.28/lib/stackprof/stackprof.so

Both installed from source (gem install --platform=ruby) in a Debian 13 container, and
additionally recompiled from gem unpack output with the loaded .so checksummed against the one
built in the same step. No non-default flags. Nothing here depends on the architecture or on
compaction — the mechanism is that the GC has no reference to the object at all.


Related: #244 is a second unmarked/unregistered VALUE in the same file, found by the same sweep. Different mechanism (a struct field vs this file-static) and a different fix, so filed separately.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions