Pseudo Graceful Process Termination Through Code Injection
Introduction
On UNIX systems, terminating programs involves sending signals, such as SIGTERM
or SIGKILL
1, to the target process.
SIGTERM
allows a process to gracefully handle the termination signal, whereas SIGKILL
abruptly ends the process.
After termination, the parent process takes charge by reading the exit status and acting accordingly.
An exit code of 0
signifies success, whereas other codes denote failures, including termination by a signal or application errors.
However, what if the need arises to forcefully terminate a process, disguising it as a successful exit?
I came across this need recently while observing a long-running process that blocked further work.
It made no progress, and I was tempted to just kill
2 it.
Since the parent process would detect the non-zero exit code, there was a high likelihood of making things worse.
This got me thinking - how can we build a tool to stop a process forcefully but make it look like a successful termination? As far as I know, Linux doesn’t provide an API to perform such a kill. So I decided to take a different route and make the process terminate itself with a zero exit code using code injection.
The Plan
Linux offers multiple APIs to alter the memory of a process.
You can use the ptrace()
3 and process_vm_writev()
4 system calls or access /proc/PID/mem
5.
Internally, all of them utilize the process tracing and debugging subsystem.
The most basic access API is ptrace()
, which allows word wise access, process_vm_writev()
is ideal if you need to
transfer more data and want to use fewer system calls.
And finally, /proc/PID/mem
lets you treat foreign memory like a file.
The plan is to stop all threads of the target program, identify a memory location where we can write code, and make one thread execute the exit_group()
6 system call with an exit code of zero.
Since we aim for a graceful exit, the program must not crash or exhibit undefined behavior as soon as we start editing the executing code.
It is crucial to halt all threads of the program before altering the program code.
Remote memory allocation into a process is not possible, so we need to modify existing memory.
Stopping only one thread carries the risk that another thread may concurrently execute code in the area we are changing, triggering an access or illegal instruction exception. Therefore, it is necessary to stop all threads to ensure a smooth modification process.
Step 1: Finding and Seizing all Threads
Linux exposes all thread IDs of a process via /proc/PID/task/
, allowing us to iterate over the list and stop each one.
It is important to repeat this process until no new threads appear.
There is always a possibility that a thread is creating a new one while we are scanning.
Additionally, we need to handle the scenario where threads exit while we are scanning the list.
Once we find a non-stopped thread, we apply the ptrace()
operations PTRACE_SEIZE
and PTRACE_INTERRUPT
.
PTRACE_SEIZE
attaches us to the thread and enables further ptrace()
operations.
PTRACE_INTERRUPT
ensures that the current system call is aborted, causing the thread to enter the stop state.
Step 2: Finding a Suitable Memory Location
After stopping all threads, the next step is to find a suitable location to write our code.
Saving the thread’s current CPU register set using PTRACE_GETREGSET
is crucial for this step.
From the register set, we can determine where the program counter was when we seized and interrupted the thread.
It is safe to assume that the program counter will point to a memory location that is executable.
Therefore, the memory page the program counter points to is guaranteed to be executable, and we can place our code there.
However, one corner case to consider is if the program counter is near the end of the page, the injected code could overlap into the next adjacent page. The adjacent page might be non-executable or not even present. To avoid this scenario, we rewind the program counter to the beginning of the page. It’s important to note that this approach works only if the code to be injected is smaller than the size of a page.
Step 3: Injecting Code
Now it is time to think about what code to inject.
Our goal is to have a thread inside the target process to execute the group_exit()
system call.
For a x86_64 system the assembly code could look like that:
mov $231, %rax ; __NR_exit_group
mov $0, %rdi ; 1st parameter to exit_group is 0
syscall ; Fire system call
Since the first two instructions set up only the system call parameters7, it is also possible to set all registers needed for the system call setup using the saved register set.
That way only one instruction opcode needs to be written into the memory of the target process.
So, the only code to write is the system call opcode. For x86_64 it is 0x0f
, 0x05
.
We could even go a step further and execute the system call without writing to any memory location at all.
By analyzing /proc/PID/maps
of the target process it is possible to scan every memory mapping that has the executable bit set for the syscall
opcode and make the program counter point to it.
The drawback of this approach is that searching for the syscall
opcode might take a long time because a significant amount of memory needs to be searched.
The final approach is:
- Rewind the program counter to the beginning of the current page.
- Set the saved registers according to the Linux syscall ABI7.
- Write the
syscall
opcode into the executable page. - Restore the registers using
PTRACE_SETREGSET
. - Continue the thread using
PTRACE_CONT
.
The attentive reader may wonder how it’s possible to write to an executable page when most memory areas in Linux are set as read-only. The answer is that these restrictions apply only to userspace. Since we’re writing through the kernel, we can easily alter read-only areas.
Once the thread is resumed, it will execute from the new program counter location.
The syscall
opcode will transition to kernel context and execute the exit_group()
system call as configured in the register set.
For x86_64, one anomaly to consider is if the thread was executing a system call when we interrupted it; the kernel may attempt to restart the system call after PTRACE_CONT
.
To disable the system call restart mechanism, we need to set the orig_rax
8 pseudo register in the saved register set to -1
.
The Result
The end result is a tool called exit0, compatible with both x86_64 and AArch64 architectures.
The only command-line parameter it requires is the target process ID.
It is capable of terminating any process for which the caller has ptrace()
permissions, with an exit code of 0
.
Summary
Code injection is not a straightforward technique and should be employed with caution. However, it is not exclusively used by malicious programs. Similar to exit0, there are legitimate use cases. exit0 can be viewed as a proof of concept. The assessment of its utility or potential danger is subjective and depends on the reader’s perspective.