Repository navigation
Conversation
…riptors open_selected_fds() rebinds every fd < PSELECT_ROUTE_NFDS whose bit appears in the poisoned fd_sets to the route's own descriptor. The bits are the waiter stamp -- payload words -- so the touched set is quasi-random per boot, and only stdio had a backup: destroy() then closed the whole set. Any long-lived descriptor in that range was silently replaced for the length of the dance and closed afterwards. Observed while porting to 5.10: the W2 victim's command pipe was hit at prepare(), the child read EOF, took its post-loop `getuid() != 0` guard and exited one dance before the cred write landed, and verify_w2_stage() then hit EPIPE on every attempt without printing anything. Snapshot every slot the pass rebinds (F_DUPFD_CLOEXEC copy plus the original FD_CLOEXEC state) before the dup2, and restore it once pselect returns, next to restore_standard_io; destroy() restores instead of closing. A slot that was already closed is closed again; a live slot whose copy failed is told apart by reading F_GETFD first and reported with its errno instead of passing as closed. The forced exception slot is installed outside the bit scan, so it joins the scan explicitly -- left out, its slot is rebound with no backup whenever the stamp leaves its bit clear. The victim's parent-held pipe ends move above the range with F_DUPFD_CLOEXEC, and a failed move fails the spawn: fork() needs no descriptor, so a child would otherwise run with an endpoint the route can clobber. The W2 verifier's two silent early returns now report errno, which separates a dead child from a live one that is not consuming. The snapshot lives outside the __ANDROID__ guard -- destroy() is host-compiled and calls the restore -- and reports through its return value instead of printing, so the fixed host test drives the round trip: an open slot, a closed slot, FD_CLOEXEC on both sides, and the forced slot with its bit left clear. Device: Redmi K50 Ultra (diting, SM8475), 5.10.236-android12-9-00003-gfb24cf99ad97-ab14313284, select_stack route, on both ROM builds that share that kernel. Before: the chain stopped at W2 on every run, silently. After: W1 -> W2 (child is root!) -> W3 -> root script at uid=0 -> KernelSU late-load, 3/3 runs of the archived series. The uncopied-slot branch is not reachable there as measured (RLIMIT_NOFILE 32768, ~149 fds in use, copies need fd >= 384). Co-Authored-By: Claude Code <noreply@anthropic.com>
Contributor
There was a problem hiding this comment.
🔵 Needs a closer look
The attack-critical descriptor lifecycle has an unresolved backup-failure bug and lacks required verification artifacts.
2 open findings
What changed in this PR
Prevents the select-stack route from permanently clobbering unrelated low-numbered file descriptors.
Changes:
- Snapshots and restores selected descriptors, including the forced slot.
- Moves victim protocol descriptors above the select range and improves W2 diagnostics.
- Adds host coverage for descriptor restoration and
FD_CLOEXEC.
| File | Description |
|---|---|
src/core/route/select_stack_route.cpp |
Implements descriptor backup and restoration. |
src/core/route/select_stack_route.h |
Exposes restoration helpers. |
src/core/session/victim_process.cpp |
Protects protocol pipes and improves diagnostics. |
src/core/tests/select_stack_route_test.cpp |
Tests descriptor round trips. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+134
to
+135
| static std::array<SelectedFdBackupEntry, PSELECT_ROUTE_NFDS> selected_fd_backup; | ||
| static size_t selected_fd_backup_n = 0; |
| } | ||
| select_errno = errno; | ||
| route::restore_standard_io(stdio_backup); | ||
| route::restore_selected_fds(); |
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.


Stop the select route from destroying unrelated file descriptors
Note
This PR is a preliminary fix for 5.10 series kernel support.
Problem
SelectStackRoutebuilds its pselect fd_sets out of the waiter stamp, so the payload words are the bitpattern:
open_selected_fds()dup2s one of the route's own descriptors over every fd <PSELECT_ROUTE_NFDS(320) whose bit happens to be set, anddestroy()closed that quasi-random set. Onlystdio had a backup. Any
long-lived descriptor in that range — app fds, and the W2 victim's protocol pipes — was silently replaced
for the length of the dance and closed afterwards.
Observed while porting to a 5.10 kernel: the victim's command pipe was replaced at
prepare(), the rootedchild read EOF, took its post-loop
getuid() != 0guard and exited about one dance before the cred writelanded.
verify_w2_stage()then hit EPIPE on every attempt and returned 0 without printing anything, soW2 retried forever against an empty log. Every write landed; the chain never completed.
Change
One commit,
fix(route): stop the select route from destroying unrelated file descriptors.Snapshot and restore. Every slot the dup2 pass rebinds is copied first — the descriptor plus its
FD_CLOEXECstate — and put back once the pselect returns, next torestore_standard_io;destroy()restores instead of closing. A slot that was already closed is closed again; a live slot whose copy failed
is told apart from a closed one by reading
F_GETFDfirst, and reported with its errno instead of passingas closed.
The forced exception slot. It is installed after the bit scan, so the scan never snapshotted it:
whenever the stamp left its bit clear, that slot was rebound with no backup — the one descriptor in the
range the pass could still destroy for good, and a live app descriptor on any device holding that many fds
open. It now joins the scan.
The victim's parent-held pipe ends move above the range with
F_DUPFD_CLOEXEC, and a failed movefails the spawn:
fork()needs no descriptor, so a child would otherwise run with an endpoint the routecan clobber.
verify_w2_stage()no longer returns 0 silently: both early returns printerrno, which separates adead child (EPIPE, 0-byte reply) from a live one that is not consuming (full pipe, EAGAIN).
Host coverage. The snapshot lives outside the
__ANDROID__guard —destroy()is host-compiled andcalls the restore — and it reports through its return value rather than printing, so the host test drives
the round trip: an open slot, a closed slot,
FD_CLOEXECon both sides, and the forced slot with its bitleft clear.
Three details worth knowing while reviewing:
PSELECT_ROUTE_NFDS + 64, above the range the pass walks — a copy inside the rangecould be poisoned by a later iteration of the same loop.
dup2clearsFD_CLOEXECon its target, so the flag is read before the dup2 and re-applied after;otherwise a close-on-exec descriptor would come back without it.
destroy()'s call is a no-op when the per-attempt restore already ran.The consumer-stuck path deliberately keeps the installed descriptors, matching
retain_for_process_lifetime().Testing
Device: Redmi K50 Ultra (
diting, SM8475),5.10.236-android12-9-00003-gfb24cf99ad97-ab14313284,select_stackroute, on both ROM builds that share that kernel (CN and Xiaomi.eu). Before: the chainstopped at W2 on every run, silently. After: W1 → W2 (
child is root!) → W3 → root script atuid=0→KernelSU late-load, across a run series on the patched and on the stock kernel.
Re-run against this commit as a regression gate, because the fixes changed the fd path after that series:
three consecutive runs, every stage on attempt 1,
exploit completeat T+31.5 s / 30.5 s / 35.2 s, rootscript at
uid=0,native exited code=0, SELinux restored to enforcing. No oops or panic in the kernellog, and none of the new diagnostics fired — this device holds ~149 fds, far below the copy range, so the
uncopied-slot branch is not reachable on it as measured.
Not tested: 6.6 / 6.12.