[Service] fix use-after-free on training offloading destroy - #693
[Service] fix use-after-free on training offloading destroy#693myungjoo wants to merge 4 commits into
Conversation
_ml_service_training_offloading_destroy() released node_table - and with it every ml_service_node_info_s - before touching the pipeline, and it never stopped the pipeline at all. Every output node registers _ml_service_pipeline_sink_cb() with its node_info as user_data, and that callback dereferences node_info->mls and node_info->name. It also hands node_info->name to the application event data without copying it. So an application that calls ml_service_destroy() on a receiver still in PLAYING - which the API allows, ml_service_stop() is not mandatory - lets a buffer reaching the sink run the callback on a freed node_info. Release the pipeline first, mirroring _ml_service_extension_destroy(): ml_pipeline_stop() takes the pipeline out of PLAYING and ml_pipeline_destroy() brings it to NULL, which joins the streaming threads, so no callback can be in flight by the time the node info goes away. Stopping first also narrows the window in which ml_pipeline_destroy() tears down its named nodes while the pipeline is still running. Addresses item H3 of nnstreamer#690. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
The existing training offloading test always stops the service before destroying it, so the teardown order was never exercised. destroyWhileRunning_p drives a receiver up to PLAYING and destroys it without stopping. The pipeline it uses is injected the way the remote sender would send it, but is self-contained (videotestsrc into tensor_sink): the teardown order is under test, not the training framework. The sink callback holds the streaming thread for 300 ms and then reads the node name back from the event data. That name is the node info's own string, passed on without a copy, so the read lands after the teardown has freed the node table if the pipeline is released too late. destroyAfterStop_p keeps the documented stop-then-destroy order working now that destroy stops the pipeline itself, and destroyInvalidParam2_n covers the guard that rejects a service which is not in training mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
2bedc28 to
ded70e8
Compare
SummaryThe product-code change is correct, minimal and well-targeted. I verified the claimed mechanism end-to-end in the tree rather than taking the description at face value:
Regression risk looks low:
Below are the issues I would like addressed before this leaves draft. None of them are in the product-code fix itself; they are all about whether the test can actually keep the bug from coming back. 1. (Medium)
|
|
Thanks — going through the review point by point. 1. (Medium) The test could not be relied on to turn CI red — fixedAgreed, and this was the right thing to lead with. Scoping it to that one call rather than I did not take the 2. (Low) Probabilistic overlap — fixedThe 10 ms poll is gone. 3. (Low)
|
VerdictNo blocking issues remain. The product-code fix is correct and I would merge it once the GBS I re-verified the two new commits ( (a)
|
Review feedback on the regression test. The sink callback and the teardown only overlapped because the callback parked for longer than the main thread took to notice it. Replace the poll with a condition variable the callback signals on entry, so the destroy always starts while the callback is held. The check that distinguishes the two teardown orders reads a string the node table has already freed in the broken order, so what it reads back is up to the allocator. Enable M_PERTURB for the duration of the destroy call, and clear it right after to keep it away from the rest of the suite. This is a second net rather than the mechanism: glibc returns from tcache_put() before free_perturb() runs, and the node name is tcache sized, so for that chunk the clobber that the assertion actually sees is tcache_put() writing its link fields over the first 16 bytes. Both are allocator behaviour, not a property of the code under test, so a sanitizer build is still the only airtight way to catch this class of defect. The comment in the test says as much. Release the ml-service handle in destroyInvalidParam2_n with _ml_service_destroy_internal(), which also drops the handle itself and its ml-option, instead of only releasing the offloading private data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
ml_pipeline_stop() can fail on a state change failure, and on Tizen it can also be refused by the feature check. Either way the pipeline is left for ml_pipeline_destroy() to pause on its own, which is the racy path the stop was added to avoid, so say so in the log the way the destroy call below already does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Signed-off-by: MyungJoo Ham <myungjoo.ham@samsung.com>
b0b223a to
fb80b6d
Compare
|
You are right about Fixed by rewording rather than by adding machinery, since there isn't machinery
I kept Agreed that a sanitizer or valgrind job is the real answer, and agreed it does Thanks also for the No functional change since your review; the branch is otherwise as you reviewed |
|
CI is green on Everything else passes too: static checks, spell check, DCO, the four Android Taking this out of draft and dropping One caveat stays open, and it is the review's item 6: |
myungjoo-bot
left a comment
There was a problem hiding this comment.
Automated review (transcribed from an AI review agent's report; please verify before acting).
Summary: The PR moves g_hash_table_destroy (training_s->node_table) after the pipeline teardown and adds ml_pipeline_stop() before ml_pipeline_destroy() in _ml_service_training_offloading_destroy(), plus three tests. The defect was confirmed on upstream/main: at c/src/ml-api-service-training-offloading.c:914-925 the node table (value free func _training_offloading_node_info_free, which frees name and the struct) is destroyed before ml_pipeline_destroy, no stop is issued, and _ml_service_pipeline_sink_cb (c/src/ml-api-service.c:954-966) dereferences node_info->mls / node_info->name and passes name uncopied through _ml_information_set -> _ml_info_set_value (ml-api-common.c:1502-1528). The fix is sound: ml_pipeline_stop sets PAUSED, which in basesink takes the preroll lock held across render and so blocks on an in-flight callback; ml_pipeline_destroy then destroys namednodes, whose cleanup_node (ml-api-inference-pipeline.c:544) takes e->lock — the same lock cb_sink_event (:307-425) holds around the user callback — and sets the pipeline to NULL, joining the streaming threads. Even on the early-error returns of ml_pipeline_destroy (:1205, :1227) namednodes is already gone, so no callback can reach node_info afterwards. The GBS x86_64 unit_test 1 log (run 34106153859) shows destroyWhileRunning_p OK (416 ms), destroyAfterStop_p OK (416 ms), destroyInvalidParam2_n OK (12 ms), 11/11 in the suite; the 416 ms destroy duration is itself evidence that the stop blocked on the parked callback. merge-tree is clean, all 10 checks pass, all four commits are DCO-signed and ordered fix-then-tests.
Also verified: no deadlock (_ml_service_destroy_internal releases mls->lock before the offloading release, so the callback's _ml_service_get_event_cb_info lock cannot contend with the blocking stop); no double free (_training_offloading_node_info_free never touches node_info->handle, which cleanup_node frees); _training_offloading_send_trained_model and the transfer_data_table destruction still precede the stop and do not touch node_table / pipeline; the M6 g_cond_clear / g_mutex_clear-before-join at :901-906 is pre-existing and byte-identical; the order now matches _ml_service_extension_destroy (ml-api-service-extension.c:716-725) except for the added stop-failure log; c/include/ml-api-service.h:262-272 does not require stop-before-destroy and already lists ML_ERROR_STREAMS_PIPE. Test gating is correct: built only under support_training_offloading (tests/capi/meson.build:69-77), run only under nntrainer_support in the spec, never compiled by the Android lanes, and <malloc.h> / mallopt are under #ifdef __GLIBC__. The injected videotestsrc ... tensor_sink pipeline does not depend on nntrainer; the GCond handshake makes the overlap deterministic; the final commit message and in-test comment correctly attribute the observed clobber to tcache_put() metadata rather than M_PERTURB. Approving; the items below are non-blocking.
- [Low]
c/src/ml-api-service-training-offloading.c:916-918: theml_pipeline_stopfailure is logged but not folded intoret, so a failed stop followed by a successful destroy returnsML_ERROR_NONE. Acceptable and consistent with the extension backend; if you want it surfaced, setreton the stop failure only when the subsequent destroy succeeds. - [Low, pre-existing, note only]
c/src/ml-api-inference-pipeline.c:544-606cleanup_nodefreeseafter unlockinge->lock; a streaming thread already insidecb_sink_eventand blocked on that lock would touch freed memory (the@todo CRITICALcomment). Stopping first shrinks that window, as the commit message says, but does not close it; worth its own item in #690 if not already there. - [Low]
tests/capi/unittest_capi_service_training_offloading.cc:467-477:mallopt (M_PERTURB, 0xAA)is process-wide while set (also affects the nns-edge thread) and is inert for the two tcache-sized frees the assertion relies on. Harmless as scoped; it could be dropped without loss of detection. No change required.
No back-door or suspicious behavior found: the product diff is a pure reordering plus one logged call inside one function; the test diff adds only local scaffolding with no network endpoints or file writes outside g_dir_make_tmp.
|
Thanks for the review. Dispositions on the three items — no code change on any of 1. Folding a failed
|
Addresses item H3 of #690.
The defect
_ml_service_training_offloading_destroy()tore the handle down in this order:g_hash_table_destroy (training_s->node_table)— frees everyml_service_node_info_sml_pipeline_destroy (training_s->pipeline_h)and never called
ml_pipeline_stop().Each output node in the received pipeline is registered with
ml_pipeline_sink_register (..., _ml_service_pipeline_sink_cb, node_info, ...),and that callback dereferences
node_info->mlsandnode_info->name. It alsopasses
node_info->nameinto the event data through_ml_info_set_value(),which stores the pointer without copying it, so a callback that is still running
when the node table goes away reads freed memory.
ml_service_stop()is not mandatory beforeml_service_destroy(), so a receiverthat is still PLAYING when the application destroys it can have a buffer reach
the sink after step 1 and run the callback on a freed
node_info.The fix
Release the pipeline before the node table, mirroring
_ml_service_extension_destroy():ml_pipeline_stop()takes the pipeline out of PLAYING.ml_pipeline_destroy()brings it toGST_STATE_NULL. It blocks on theelement lock that
cb_sink_event()holds for the whole user callback, andjoins the streaming threads, so no sink callback can be in flight once it
returns.
node_tabledestroyed.Stopping first also narrows the separate window in which
ml_pipeline_destroy()frees its named nodes while the pipeline is still running.
Tests
destroyWhileRunning_pdrives a receiver to PLAYING and destroys it withoutstopping — the exact reproduction from the issue. The pipeline is injected the
way the remote sender would send it, but is self-contained (
videotestsrcintotensor_sink) so the test covers the teardown order rather than nntrainer.The sink callback signals a condition variable on entry and then holds the
streaming thread for 300 ms; the test waits for that signal before destroying,
so the destroy always overlaps the callback rather than usually overlapping it.
After the hold, the callback reads the node name back out of the event data.
With the old order the node table is freed while the callback is still parked,
so that read lands on released memory.
What makes that read fail is allocator behaviour, and it is worth being precise
about which.
M_PERTURBis enabled for the duration of the destroy call — andcleared right after, to keep it off the rest of the suite — but glibc returns
from
tcache_put()beforefree_perturb()runs, and the node name is tcachesized, so for that chunk
M_PERTURBis inert. The clobber the assertionactually sees is
tcache_put()writing its own link fields over the first 16bytes of the freed chunk, which is reliable on glibc but is still not a property
of the code under test.
M_PERTURBstays as a second net for the paths wherethe chunk does not go to the tcache. A sanitizer or valgrind job would be the
only airtight net here; that is repository-wide CI work and belongs in its own
issue.
destroyAfterStop_pkeeps the documented stop-then-destroy order working nowthat destroy stops the pipeline itself, and
destroyInvalidParam2_ncovers theguard that rejects a service which is not in training mode.
Deliberately left out
_training_offloading_send_trained_model()still runs before the stop.Moving the stop above it would not make the transfer deterministic:
ml_pipeline_stop()only pauses, it does not maketensor_trainerfinalizethe model file, so an earlier stop could ship a different model rather than a
more complete one. That is a behaviour decision for the training offloading
protocol, not part of this use-after-free.
g_cond_clear/g_mutex_clearbeforeg_thread_joinin the samefunction. Real UB, and tracked separately as item M6 of [C-Api] Memory-safety audit of C sources: 47 verified issues (6 HIGH / 15 MEDIUM / 26 LOW) — tracking checklist #690, which also
covers the
is_receivedreset and the spurious-wakeup predicate in_training_offloading_check_received_data(). Fixing only the teardown halfhere would leave that item ambiguous.
Not verified locally
These tests need nnstreamer, nnstreamer-edge, mlops-agent and nntrainer, which
are not installed on the machine this was written on, so they have not been run
locally — only the GBS CI job with
unit_test 1exercisesunittest_capi_service_training_offloading.🤖 Generated with Claude Code