TELCORE-339: fix use-after-free and data races in record_callback transfer handling - #635
Open
damirn wants to merge 4 commits into
Conversation
tests/unit/test_record_transfer_helper_uaf.c: four test groups around the record_callback SEGV (reading a freed record_helper at switch_ivr_async.c:1551): 1. deterministic ownership-contract test proving the shared record_helper and three broken invariants after switch_ivr_transfer_recordings() 2. concurrent transfer/teardown/write race harness (ASan oracle) 3. hangup-during-transfer sweep 4. deterministic revert-path test (write-locked target forces the INIT handshake timeout), dead-target abort, and a boundary sweep across the 2s INIT-timeout / 1s thread-retry collision window
After switch_ivr_transfer_recordings() the channel-private recording handle
(set by switch_ivr_record_session_event via switch_channel_set_private) was
left pointing at the destroyed bug on the old session, and never published on
the new owner. Consequences, each demonstrated by
tests/unit/test_record_transfer_helper_uaf.c test 1:
- by-name stop/pause/mask on the old session dereferenced a destroyed bug
- switch_ivr_stop_record_session() reported SUCCESS while removing nothing
- the new owner could not address the recording it owned at all
- the old channel could never record the same filename again
("Already recording" from the stale handle)
Fixes:
- switch_core_media_bug_transfer_callback(): move the private handle to the
new session atomically with a successful transfer
- switch_ivr_stop_record_session(): propagate switch_core_media_bug_remove()
status and clear the handle on success
- record_callback(CLOSE): clear the handle when it still points at the bug
being closed
…ccesses ThreadSanitizer (tests/unit/test_record_transfer_helper_uaf run under --enable-thread-sanitizer) flagged data races behind the record_callback SEGV: 1. record_helper handshake fields (thread_needs_transfer, thread_ready, bug, recording_session, transfer_from_session, transfer_complete, read_impl) were accessed bare from record_callback (media threads), recording_thread and the transfer path's user_data dup. Add rh->flag_mutex (leaf lock) and make the transfer handshake a proper offer/commit-or-cancel protocol: the INIT-side sanity timeout and the recording thread's latch are now mutually exclusive, so exactly one side wins at the ~2s collision window (INIT sanity 200x10ms vs thread retry cadence of 1s) instead of both proceeding with disagreeing state. 2. recording_thread held cond_mutex across the whole loop body including switch_core_file_write(), so a busy drain (slow storage) blocked record_callback(INIT/CLOSE) on that mutex indefinitely - observed as a wedge during a transfer under valgrind. The mutex is now held only across the cond_timedwait itself. 3. record_callback(CLOSE) only joined the recording thread when thread_ready was set; closing during thread startup skipped the join and destroyed the helper pool under the starting thread. Join whenever the thread exists. 4. session->bugs head and SSF_MEDIA_BUG_TAP_ONLY were peeked with no lock on the read/write frame paths while the transfer path relinks bugs under bug_rwlock, and the TAP_ONLY bit was RMW-updated on session->flags under bug_rwlock while e.g. SSF_DESTROYED is written under the session rwlock - a lost-update hazard on the shared flags word. The tap-only hint is now a dedicated session->bug_tap_only field maintained and read strictly under bug_rwlock, and the flagged frame-path gates take the rdlock. (The three equivalent video-path gates keep the old peek pattern for now; they were not exercised/flagged and can follow separately.)
…ful stop
The teardown thread's dangling oracle compared post-stop helper pointers
unconditionally. When the recording is mid-transfer, both stop("all") calls
miss the in-flight bug, nothing is destroyed, and the helper legitimately
survives - which the oracle miscounted as a dangling reference (one false
positive observed under TSan scheduling). Gate the check on at least one stop
having actually removed something.
minhtuan1407-telnyx
approved these changes
Aug 7, 2026
dev-ryanc
approved these changes
Aug 7, 2026
dev-ryanc
left a comment
Collaborator
There was a problem hiding this comment.
PRBot automated review — no critical issues found. Approved based on: human approval from minhtuan1407-telnyx + clean review.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
Segfault in
record_callback()on b2bua.tel-fl1-prox-prod-711 (TELCORE-339).The reported frames were symbolized without the minus-one adjustment, so each points a line past its call site. Decoded: the crash is
record_callback()dereferencing a freedstruct record_helperat the ownership guard, reached fromswitch_core_session_write_frame()→switch_core_media_bug_prune()→switch_core_media_bug_close()→ the CLOSE callback.Three things combine:
record_helperlives in its own memory pool, not the session pool, andrecord_helper_destroy()destroys that pool — so the helper dies independently of any session.switch_ivr_record_user_data_dup()copies nothing; it mutatesrecording_session/transfer_from_sessionand returns the same pointer, which the transfer installs on the new bug.SWITCH_FALSEfrom the WRITE callback setsSMBF_PRUNE, which prunes and invokes the callback a second time withABC_TYPE_CLOSE— a second dereference, later in time. That second dereference is frames 0–2 of the reported stack.Underneath it, the real trigger: the helper's handshake fields (
thread_needs_transfer,thread_ready,bug,read_impl,recording_session) are read and written from three threads with no common lock — plain C data races, i.e. undefined behaviour. Plus two on the media hot path, directly on frame 3 of the crash stack:session->bugspeeked with no lock while the transfer relinks bugs underbug_rwlock, andSSF_MEDIA_BUG_TAP_ONLYas a non-atomic RMW onsession->flagsracingSSF_DESTROYED(written under the session rwlock) — a lost-update hazard on a shared flags word.Changes
2b30e05896— test harness.tests/unit/test_record_transfer_helper_uaf.c: a deterministic ownership-contract test, a concurrent transfer/teardown/write race harness, a hangup-during-transfer sweep, and a deterministic revert-path test (write-locked target forces the INIT handshake timeout) with a boundary sweep across the 2s-INIT-timeout / 1s-thread-retry collision window.f7dcfca7b5— ownership bookkeeping. After a transfer the channel-private recording handle was left pointing at the destroyed bug on the old session and never published on the new owner. Consequences: by-name stop/pause/mask on the old session dereferenced a destroyed bug; the new owner could not address the recording it owned; and that channel could never record the same filename again ("Already recording" from the stale handle). Now the handle moves with the transfer, is cleared on CLOSE, andswitch_ivr_stop_record_session()propagates the real removal status.9f91eebe79— synchronization. Addsrh->flag_mutex(leaf lock) over the shared helper state. The substance is that the transfer handshake became a real protocol: INIT offers the transfer, then either INIT's timeout cancels it or the recording thread commits it, atomically — exactly one side wins at the collision window, instead of both proceeding with disagreeing state. Also:cond_mutexis held only across thecond_timedwait, not acrossswitch_core_file_write()— removes a wedge where slow/remote storage blocks a transfer indefinitely (presents as a stuck session at 100% CPU, not a crash).record_callback(CLOSE)joins the recording thread whenever it exists, not only whenthread_readywas set — closing during startup used to skip the join and free the pool under the starting thread.SSF_MEDIA_BUG_TAP_ONLY→ a dedicatedsession->bug_tap_onlymaintained and read strictly underbug_rwlock.session->bugshead read underbug_rwlockon the flagged frame paths, inswitch_core_media_bug_pop(), and inremove_callback()'s tail.0700436a51— harness correction. The dangling-helper oracle now only counts when a stop actually removed something; mid-transfer both stops legitimately miss and the surviving helper is not dangling.Testing
Confirmed, not reproduced — stating that plainly. The defect is proven; the literal SIGSEGV was not replayed.
Deterministic (fails on the unfixed tree): the ownership test prints the root cause directly — two distinct media bugs carrying one identical helper pointer — and three ownership assertions fail (A retains the destroyed-bug handle; B has none; stop on the owner returns FALSE).
ThreadSanitizer is what nailed the mechanism, because it flags the racy access pair without needing the bad outcome to occur. A TSan build produced 95 data-race reports; after dedup, the record/transfer races were exactly the predicted set with file:line, e.g.:
Before vs after, same build and suite:
thread_needs_transferhandshakethread_readystartupread_implstruct write vs thread readsession->flagsRMW vsSSF_DESTROYEDbug_pop/remove_callbackhead peeks6/6 tests pass under TSan (23s): the three previously-red ownership assertions green, race harness at 600 transfers / 312 real helper destroys /
dangling=0, 10 hangup rounds, and the revert boundary sweep arbitrating cleanly (4 reverts / 4 successes).Negative results, recorded honestly: ASan (~290 transfers, ~370 helper destroys, ~50M write frames, 120 hangup rounds) produced no report — and the detector was validated in the same binary, so that silence is a real negative, not a blind spot. valgrind memcheck found 1 error total, a pre-existing benign uninitialised read at startup, unrelated. Every interleaving reachable through the current core APIs converges before the freed read, which is why the production crash is most plausibly the UB from the data races — flagged by TSan, not replayable by ASan/valgrind.
Notes for review
switch_ivr_stop_record_session()now returns the real status (FALSE where it previously reported SUCCESS against a stale handle), and the channel-private handle now travels with the transfer. Anything depending on the old (broken) bookkeeping will notice.switch_set/clear/test_flagbit-op pattern (FreeSWITCH-wide idiom, needs its own effort, not on the crash mechanism); three video-path gate twins that keep the old unlocked peek (unexercised here); and the shared-helper design itself — ownership is now correct and synchronized, but a refcount would be the belt-and-suspenders follow-up.