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 start → stop 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.
objtraceris a file-staticVALUEthat is never registered with the GC —StackProf.stopcan dereference a recycled object slotRepo: tmm1/stackprof · Affected: 0.2.26, 0.2.28, and
maintoday · Latent, see §Reachability1. Summary
stackprof.ckeeps the object-modeTracePointin a file-staticVALUE,objtracer. It iswritten in
StackProf.start(mode: :object)and read back in a laterStackProf.stop, but it isnever passed to
rb_global_variableand 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
start→stoppath nothing goes wrong. This is alatent 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.stopdereferencesthe recycled slot. Measured outcomes, all deterministic (3/3) on 0.2.26 and 0.2.28:
StackProf.stopStringTypeError: wrong argument type String (expected tracepoint)T_NONE)stopCFUNCTracePointtrueand silently disables somebody else's tracepointThe 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.stopin object mode the tracepoint isdisabled and unreferenced, so
objtracerholds a freedVALUEfrom that moment until the nextStackProf.start(mode: :object)overwrites it. Nothing reads it in between, so it is inert — butthe 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.
Two non-obvious steps, because they are the difference between reproducing and not:
tp = nil. ATracePointheld in a live local is conservatively pinned by the machinestack scan and will never be collected, so the bug cannot appear. Measured: keeping
tpalivegives
tracepoints 1 -> 1after the same 200,000 allocations andStackProf.stopreturnstrue.size pool. Here
ObjectSpace.dump(tp)["slot_size"]is 160, and a 100-byteStringhasslot_size160 too. A 10-byte filler lands in a different pool and the slot staysT_NONE.tracepoint is enabled it is pinned: measured with 200 fresh witness Strings under
GC.verify_compaction_references(expand_heap: true, toward: :empty)— 200/200 witnessesrelocated while the tracepoint's address did not change, on both pins.
Two variants of the last two steps, each also 3/3:
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 theString-recycling path uses no explicitGC.starteither,only allocation.
Controls (same script, flags): with no external disable, and with an external disable but no
allocation afterwards,
StackProf.stopreturns normally — 3/3 each, both pins. So it is thecollection, 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):and in
Init_stackprof, where three neighbours are registered and this one is not(0.2.28 line numbers;
mainis +1 on each):4. Affected versions
rb_global_variable(&objtracer)main(ext/stackprof/stackprof.c, fetched today)maindiffers from 0.2.28 only instackprof_record_sample_for_stack, which now takes the frameand line buffers as parameters; the three
objtracerlines and theInit_stackprofregistrationsare 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, inInit_stackprof:Ran it. Patched
/src/stackprof-{0.2.26,0.2.28}, rebuilt, and re-ran the same reproducer withthe same flags in the same step as the build: the
TracePointis no longer collectable, theprecondition cannot fire, and
StackProf.stopis normal — 3/3 on both, while the unpatched buildfrom the identical source tree fails 3/3.
If you would rather not keep the last tracepoint alive forever, the alternative is
objtracer = Qnil;afterrb_tracepoint_disable(objtracer)instackprof_stopplus aRTEST(objtracer)guard — but that leaves the window betweenstartand an externaldisablestill unguarded, so the
rb_global_variableform 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 writein
stackprof_start, and the single read instackprof_stop. That read is guarded by_stackprof.runningand_stackprof.mode == sym_object, and for the whole of that window thetracepoint is enabled, hence held by
vm->global_hooksand pinned (measured above, 200/200witnesses). Walking stackprof's own entry points:
StackProf.stoptwice → the second call early-returns on!running.StackProf.results→ callsstackprof_stoponly when running.StackProf.start(mode: :object)twice → the second call reassignsobjtracer.StackProf.start(mode: :wall|:cpu|:custom)after an object-mode stop →modeis no longersym_object, soobjtraceris 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#disableon stackprof's tracepoint while stackprof stillbelieves it is running.
Searched for such a caller and did not find one:
gh search code 'ObjectSpace.each_object(TracePoint)'(30 results) is entirelyruby/mspec'slib/mspec/runner/actions/leakchecker.rband its vendored copies, plus ruby's owntest/ruby/test_settracefunc.rb— and mspec only reports tracepoints left enabled, it does notdisable them.
grep -rl 'each_object(TracePoint)'over the installed gem corpus used for thisaudit: 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
VALUEall the same, the fix is oneline, 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:
Both installed from source (
gem install --platform=ruby) in a Debian 13 container, andadditionally recompiled from
gem unpackoutput with the loaded.sochecksummed against the onebuilt 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
VALUEin the same file, found by the same sweep. Different mechanism (a struct field vs this file-static) and a different fix, so filed separately.