Skip to content

Hide benchmark arguments from the imported kernel - #54

Open
LaelaZorana wants to merge 1 commit into
gpu-mode:masterfrom
LaelaZorana:fix/issue-3-hide-args
Open

LaelaZorana wants to merge 1 commit into
gpu-mode:masterfrom
LaelaZorana:fix/issue-3-hide-args

Conversation

@LaelaZorana

@LaelaZorana LaelaZorana commented Sep 17, 2026 •

Copy link
Copy Markdown

Issue #3.

An imported kernel puts the benchmark arguments in easy reach. do_bench of binding.cpp dispatches the kernel to a second thread, run_thread, yet _do_bench_impl is left on the main thread stack. A walk from the kernel's own thread can not see that frame since #43, but sys._current_frames still puts the top one for every thread at your disposal. The main thread has as its locals out_fd, the result pipe, and in_fd, along with supervisor_sock, test_args and test_generator. Step down a frame to BaseProcess.run and there is self._args, the tuple used to start the child.

In order to separate the runner from these descriptors, _do_bench_impl will now make duplicates of them and then close the three connection objects. It also does away with the argument names in the frame and clears _kwargs and _args of the child Process. The call is made by popping it off a list so the tuple resides on the C++ side and interpreter stack during the kernel run but off every frame. Any exec in user code will leave the pipes be as os.dup returns FD_CLOEXEC descriptors. The except handler can still make use of the traceback pipe on the frame. It is text bound for the parent.

I tried a helper to get at the caller's frame with f_locals. It went nowhere. 3.12 treats f_locals as a snapshot which precludes writing to fast locals and the proxy in 3.13 refuses to delete a name. With 3.12 being the floor for this repo I have resorted to plain del in _do_bench_impl.

This is what the suite in test/test_hidden_arguments.py is for, using an extension stub on CPU. A stand in for the child Process is employed to call _do_bench_impl in one test to have the stack, with its run frame and _args, be as it would be in a spawn. Another will do the same in a proper spawned child. The worker thread then goes through sys._current_frames to look for the five names, and for the generator, the arguments dict and any Connection object by identity, in frame locals, tuples, lists and _args. Prior to this change they are all there. Now the walk is empty but the result pipe continues to write. I have put it to the test on 3.12, 3.13 and 3.14. Revert the fix and four of the five fail while the control still passes.

The exploits under exploits/ such as heap_sig_scan, stack_sig_scan, pipe_interpose, file_struct and seccomp_trap are unaffected. They use their own thread for sys._getframe and #43 has already put them off this frame. Ditto for aes_key_recovery and proc_mem_bypass which are dependent on gc.get_objects.

Then there is gc.get_objects. So long as the runner has them a heap scan will find the live python objects of the tuple and generator. The only way to put them out of sight is to keep the expected output out of python before the import, like the approach of the deleted file in the issue, but that is more of an undertaking.

The benchmark call arguments were reachable from an imported kernel. The
compiled do_bench runs the kernel on a second thread while _do_bench_impl is
still on the stack of the main thread, so sys._current_frames hands the kernel
that frame, with the result pipe and the expected output generator bound as
locals. One frame further down BaseProcess.run holds self, and self._args is
the tuple the child was started with. Issue gpu-mode#3.

_do_bench_impl now hands the runner duplicated descriptors of its own and
closes the three connection objects, clears _args and _kwargs on the child
Process object, deletes the argument names from its own frame, and passes the
packed call by popping it from a list. The tuple lives on the interpreter
stack and on the C++ side while the kernel runs, and every frame is clear of
it. os.dup returns descriptors with FD_CLOEXEC set, so an exec from user code
leaves the pipes behind. The traceback pipe stays on the frame because the
except handler needs it.

test/test_hidden_arguments.py runs on CPU with the extension stubbed. It calls
_do_bench_impl through a stand in for the child Process object and, in one
test, in a real spawned child. From a worker thread it walks every thread
through sys._current_frames and looks for the five names, and for the
generator, the arguments dict and any Connection object by identity, in frame
locals, inside tuples and lists, and inside _args. Before the change it finds
all of them, after the change it finds none of them, and the result pipe still
writes.
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.

1 participant