Conversation
rpc_exec_process_cb() closes the stdout/stderr pipe descriptors and then sets the stored uloop_fd.fd to -1, so that the second close() in rpc_exec_reply() cannot hit an unrelated descriptor that was reused in the meantime. The unregistration from uloop happens only afterwards though: rpc_exec_reply() calls ustream_free(), which calls uloop_fd_delete(), which by then finds -1 in the descriptor field and issues epoll_ctl(EPOLL_CTL_DEL, -1). That fails with EBADF. No caller checks the return value, so the failure is silent and the registration stays in the epoll set, still pointing at &c->opipe.fd -- memory that rpc_exec_reply() frees a few lines later with free(c). Usually this stays invisible, because closing the last descriptor of an open file description drops its epoll registrations as a side effect. It stops being invisible as soon as another process holds a copy of the same pipe: the file description then outlives the close(), and so does the stale registration. When the pipe reports EPOLLHUP, uloop_fetch_events() takes the dangling uloop_fd out of epoll_event.data.ptr, writes to it (u->error = true) and dereferences it further, which terminates rpcd with SIGSEGV. Concurrent rpc_exec() calls produce exactly that situation, because a child forked for one call inherits the pipes of the calls still in flight. Delete the fds from uloop while they are still valid. The -1 assignment and the double-close protection it provides are left in place. Signed-off-by: xyzmean <yo1nkxxd@gmail.com>
The three pipes of an rpc_exec() call are created with plain pipe(), so a child forked for one call inherits every pipe belonging to the calls that are still in flight, and passes them on to whatever it execs. Two consequences. The descriptors leak into every process spawned by exec based plugins, which have no business holding them. And, more seriously, an unrelated sibling keeps the pipes of an already finished call open, so the file description outlives the close() in rpc_exec_process_cb() -- which is the condition that turns the stale epoll registration fixed in the previous commit into a use-after-free. Use pipe2() with O_CLOEXEC. dup2() onto 0/1/2 in the child does not carry the flag over, so each child keeps its own three pipes. _GNU_SOURCE is needed for pipe2() and is defined the same way in file.c and session.c. Signed-off-by: xyzmean <yo1nkxxd@gmail.com>
xyzmean
added a commit
to xyzmean/splify2
that referenced
this pull request
Sep 7, 2026
… труб соседних вызовов роняет rpcd rpcd создаёт трубы каждого вызова без O_CLOEXEC, и ребёнок, порождённый для одного вызова, наследует трубы всех вызовов, что в этот момент в полёте. Закончив чужой вызов, rpcd закрывает свою сторону трубы, а из epoll её не снимает — регистрация остаётся и указывает на освобождённую память; когда труба наконец закрывается, epoll приносит EPOLLHUP в мусор, и rpcd падает с SIGSEGV, после чего LuCI просит войти заново. На живом роутере это выглядело как падение rpcd во время фоновой проверки стратегий: её процесс держал трубы каждого опроса страницы. Исправление самого rpcd — openwrt/rpcd#41; пока оно не во всех прошивках, держателем чужих труб не должны быть мы. Скрипт закрывает всё старше 2 сразу после запуска — и для себя (долгая установка пакета перестаёт переживать соседний опрос с его трубами), и для всего, что запустит в фоне: ни `start-stop-daemon -b`, ни `&` унаследованного не закрывают (проверено на роутере). Номера труб у rpcd двузначные (14…18 с LuCI), busybox ash их закрывает; dash со стенда — нет и ругается, поэтому ошибка глушится. Дескриптор самого файла скрипта узнаётся по `-ef`, а не по номеру.
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.
What this fixes
rpcdcan be terminated by SIGSEGV through a use-after-free inexec.cwhenseveral of its methods run at the same time. The two commits fix the two
independent halves of it.
1. The epoll registration is never removed
rpc_exec_process_cb()closes the stdout/stderr pipe descriptors and then setsthe stored descriptor to
-1:The intent of that
-1is sound, but the unregistration from uloop only happensafterwards, in
rpc_exec_reply()viaustream_free()→uloop_fd_delete().By then the descriptor field is
-1, so theepoll_ctl(EPOLL_CTL_DEL, -1)performed there fails with
EBADF. No caller checks that return value, so thefailure is silent and the registration stays in the epoll set, still pointing at
&c->opipe.fd— memory thatrpc_exec_reply()frees a few lines later withfree(c).2. Children inherit the pipes of their siblings
The pipes are created with plain
pipe(), withoutO_CLOEXEC. A child forkedfor one call therefore inherits every pipe belonging to the calls that are still
in flight, and passes them on to whatever it
execs.When it surfaces
Neither half is enough on its own, which is why this is easy to miss.
Normally the stale registration is harmless: closing the last descriptor of an
open file description drops its epoll registrations as a side effect, so the
kernel cleans up what
uloop_fd_delete()failed to remove.That only holds while nobody else has a copy of the pipe. With concurrent calls
somebody does — see (2). A sibling child keeps the file description of an already
finished call alive, so the stale registration stays valid past
free(c). Whenthe pipe finally reports
EPOLLHUP,uloop_fetch_events()picks the danglinguloop_fdout ofepoll_event.data.ptr, writes to it (u->error = true) anddereferences it further.
So the crash needs two calls to overlap, and it needs one of them to finish while
the other is still running. Single, serialized calls never trigger it — which is
why it tends to show up under web frontends that issue several ubus calls in
parallel while a page is open, and not in manual
ubus calltesting. The visiblesymptom is
rpcddying and being restarted by procd; since sessions live in itsmemory, every logged-in user is asked to log in again.
The mechanism can be reproduced without rpcd at all:
Closing
copyafterwards makes the registration disappear on its own, which isexactly why the bug is invisible without concurrency.
The fix
uloop_fd_delete()for both pipes inrpc_exec_process_cb()beforeclosing them, while the descriptors are still valid. The existing
-1assignment and the double-close protection it provides are left untouched.
pipe2(..., O_CLOEXEC).Either change alone removes the crash. Keeping both is deliberate: the first
fixes the actual lifetime bug, the second removes the condition that makes it
reachable, and additionally stops leaking descriptors into every process spawned
by exec-based plugins.
dup2()onto 0/1/2 in the child does not carry the flagover, so the child's own pipes are unaffected.
Testing
Built against
25.12-SNAPSHOTand run on aramips/mt7621device(OpenWrt 25.12.5, musl). A script issuing the same burst of parallel calls that a
web frontend makes crashes stock
rpcdon the first round; with these commits itcompleted 40 rounds without a crash.
The original diagnosis came from a core dump on that device:
pcinuloop_run_timeout(), with theepoll_eventarray holding{events=0x10, data.ptr=0x77c21780}where that address is not mapped in thedump at all.