Skip to content

Stop the select route from destroying unrelated file descriptors - #403

Open
Haoyu-UT wants to merge 1 commit into
YuKongA:kernel-5.10-devfrom
Haoyu-UT:pr/select-fd-clobber-v2
Open

Haoyu-UT wants to merge 1 commit into
YuKongA:kernel-5.10-devfrom
Haoyu-UT:pr/select-fd-clobber-v2

Conversation

@Haoyu-UT

Copy link
Copy Markdown

Stop the select route from destroying unrelated file descriptors

Note

This PR is a preliminary fix for 5.10 series kernel support.

Problem

SelectStackRoute builds its pselect fd_sets out of the waiter stamp, so the payload words are the bit
pattern: open_selected_fds() dup2s one of the route's own descriptors over every fd <
PSELECT_ROUTE_NFDS (320) whose bit happens to be set, and destroy() closed that quasi-random set. Only
stdio 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 rooted
child read EOF, took its post-loop getuid() != 0 guard and exited about one dance before the cred write
landed. verify_w2_stage() then hit EPIPE on every attempt and returned 0 without printing anything, so
W2 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_CLOEXEC state — and put back once the 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 from a closed one by reading F_GETFD first, and reported with its errno instead of passing
as 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 move
fails the spawn: fork() needs no descriptor, so a child would otherwise run with an endpoint the route
can clobber.

verify_w2_stage() no longer returns 0 silently: both early returns print errno, which separates a
dead 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 and
calls 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_CLOEXEC on both sides, and the forced slot with its bit
left clear.

Three details worth knowing while reviewing:

  • Backups are placed at PSELECT_ROUTE_NFDS + 64, above the range the pass walks — a copy inside the range
    could be poisoned by a later iteration of the same loop.
  • dup2 clears FD_CLOEXEC on 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.
  • The restore zeroes its count, so 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_stack route, on both ROM builds that share that kernel (CN and Xiaomi.eu). 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, 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 complete at T+31.5 s / 30.5 s / 35.2 s, root
script at uid=0, native exited code=0, SELinux restored to enforcing. No oops or panic in the kernel
log, 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.

…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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants