Full soultion: https://github.com/fadeevab/TinyInjector

One of the ways to carry out the shared library injection is to use ptrace system call (syscall). One process (the tracer) attaches to a tracee and calls dlopen inside tracee's virtual memory space.

injection-via-ptrace-overview

Root privileges are required to attach to any process in the system and to overcome other security controls. It is possible to use ptrace without root, but in the same user ID. It's assumed that SELinux is enforced, as well as dm-verity and few other security controls.

Environment State
root privileges 🔓
SELinux 🔒
dm-verity 🔒
ASLR 🔒
linker namespace 🔒

SELinux - LSM module aimed to provide Mandatory Access Control (MAC).
dm-verity - Linux kernel module to protect read-only partitions (/system, /vendor).
ASLR - address space layout randomization.
Linker namespace - restrictions applied to dynamic linking.

When a tracer attaches to a target process (tracee), it's possible to read/write data from/to the process' memory, read/write registers, to call mmap, mprotect, to manipulate with memory, to call any system API.

The ptrace syscall is not the only way to trap into a remote process, but the most obvious and powerful one.

I wish to express my thanks to the author shunix for the series of articles about Shared Library Injection in Android.

  1. Shared Library Injection in Android (ASLR bypass is there as well).
  2. Bypass SELinux on Android.
  3. ARM and Thumb Instruction Set.

I use his TinyInjector, as a basis, and extend one to the Android 8.0 realms, particularly to the ARM64 architecture (original version supports 32-bit ARM), SELinux, and the linker namespace.

More About Ptrace

The help topic of "SELinux Policy Manager" has a pretty nice overview of ptrace (lockdown_ptrace.txt):

Most people do not realize that any program they run can examine the memory of any other process run by them. Meaning the computer game you are running on your desktop can watch everything going on in Firefox or a programs ... that attempts to hide passwords.
ptrace allows developers and administrators to debug how a process is running using tools like strace, ptrace and gdb. You can even use gdb (GNU Debugger) to manipulate another process running memory and environment.
The problem is this is allowed by default.
... It would be more secure if we could disable it by default.
Note: Disabling ptrace can break some bug trappers that attempt to collect crash data.

If you ever thought about any system security hardening, you would have to think about to disable ptrace (together with root user). The problem to just disable them all is laid in CIA principle: Confidentiality, Integrity, Availability. Improving the integrity/confidentiality usually leads to degradation of availability. Disabling the ptrace makes the system to be almost undebuggable and unmaintainable.

Preconditions

In order to attach to any process via ptrace system call, it is necessary:

  1. to have root privileges
  2. to be in a proper SELinux context allowed to use ptrace

Root is needed to:

  1. Attach via ptrace to any process.
  2. List all processes.
  3. Read /proc/<pid>/maps.
  4. Change SELinux labels.

If you don't have a root, the injector (tracer) needs to have a tracee's UID to be able to attach to a tracee process.

ptrace-users

If /proc filesystem is mounted with hidepid option, then an ordinary non-root process is not able even see other processes in the system.

$ grep proc /proc/mounts
proc /proc proc rw,relatime,gid=3009,hidepid=2 0 0

$ ps
PID   USER    COMMAND
17677 u0_a155/data/data/bash/bash -i
21955 u0_a155ps

Disabling SELinux: setenforce 0

I haven't had to disable SELinux on my Android 8.0 device. Only root. I tested TinyInjector, launching one from the /data partition, and it works just fine in Android 8.0 having only root:

# On the device under root
/data/local/tmp/injector com.android.example.app /data/local/tmp/libinject.so

But you may not be able to attach injector to another process with SELinux enforced. Current version of TinyInjector contains the code to disable SELinux through selinuxfs. The trick to disable SELinux may be shortly described as a shell script:

# get actual selinuxfs directory here, e.g. /sys/fs/selinux
grep selinuxfs /proc/mount
# disable SELinux; under the root
echo 0 > /sys/fs/selinux/enforce

Disabling SELinux: playing with labels

I find the way above to disable SELinux to be a pretty rough. Depending on a vendor of your device the selinuxfs may not be mounted (/sys/fs/selinux may not be present in such case). Disabling SELinux may not be allowed on the device. In such case there are few other ways to deal with SELinux to allow ptrace:

  1. Search for a context which is allowed to use ptrace,
  2. either set a desired label to injector using semanager or chcon tool,
    chcon -v u:object_r:apk_data_file:s0 injector
    
  3. or place injector near the tracee and trap into the context using restorecon,
  4. or find any other way to trap into desired SELinux context, e.g. through any standard Android behavior which leads to obtaining the desired context or the tracee's user ID.

dm-verity

dm-verity protects read-only partitions such as /system and /vendor. Pushing the binaries into /data partition allows to evade altering the protected partitions.

adb push injector /data/local/tmp/
adb push libinject.so /data/local/tmp/
adb shell /data/local/tmp/injector com.android.example.app /data/local/tmp/libinject.so

How Injection Is Performed

  1. An injector attaches to a target process via ptrace.
  2. The target process is suspended.
  3. The injector gets a content of registers saving the running context of the target.
  4. The injector finds symbols mmap, dlopen and dlsym in the virtual memory of the target process.
  5. mmap is used to allocate memory for the string parameters and for the stack.
  6. dlopen loads a shared library.
  7. dlsym retrieves some function from the newly loaded shared library.
  8. The function obtained via dlsym could be called.
  9. The injector restores registers of the target process restoring a running context.
  10. The target process continues as usually while the new shared library is injected and some custom function is called from that library.

Porting TinyInjector to AARCH64 (ARM64)

ptrace() ARM64 Implementation

ptrace() prototype is fairly generalized, but functionality is strongly tied to hardware: ptrace allows to manipulate registers, and concrete list of registers depends on assembler, and assembler comes from architecture of CPU. "Back-end" of ptrace is provided by Linux kernel implementation.

#include <sys/ptrace.h>

long ptrace(enum __ptrace_request request, pid_t pid,
            void *addr, void *data);

For some reasons ARM64 ptrace usage pretty differs from ARM32.

#if defined(__aarch64__)
  #define pt_regs user_pt_regs
#endif

static void PtraceGetRegs(pid_t pid, struct pt_regs *regs) {
  #if defined(__aarch64__)
    struct {
      void* ufb;
      size_t len;
    } regsvec = { regs, sizeof(struct pt_regs) };
    ptrace(PTRACE_GETREGSET, pid, NT_PRSTATUS, &regsvec);
  #else
    ptrace(PTRACE_GETREGS, pid, NULL, regs);
  #endif
}
ARM32 (AARCH32) ARM64 (AARCH64)
PTRACE_GETREGS PTRACE_GETREGSET
pid pid
NULL NT_PRSTATUS
struct pt_regs * { struct user_pt_regs *ubf, size_t len }

(Obviously, additional control is provided to the size of the 4th parameter void *data).

Another inconvenience has come from the struct pt_regs definition: the similar struct user_pt_regs is introduced instead, and the register shortcuts are absent. I added some macro to make code more portable (I didn't find a better way):

#if defined(__aarch64__)
  #define pt_regs  user_pt_regs
  #define uregs    regs
  #define ARM_r0   regs[0]
  #define ARM_lr   regs[30]
  #define ARM_sp   sp
  #define ARM_pc   pc
  #define ARM_cpsr pstate
#endif

X0 is a 64-bit analogue of 32-bit R0.
X30 is a return address.
PSTATE is a processor state register.

ARM64/ARM32 ABI Differences

ABI formula:
ABI = PCS + ELF + DWARF + ...

Concretely, PCS interests us in the current scope.

PCS (Procedure Call Standard) is a CPU rules to make a procedure call: how to pass arguments, how to return the value, what registers are expected to be modified during call, how the stack should be used, and so on.

In the ARM32 PCS the first 4 parameters are passed through R0-R3 registers, and the rest of the arguments are passed through the stack.

for(int i = 0; i < argc && i < 4; ++i) {
  regs.uregs[i] = args[i];
}

In the ARM64 PCS the first 8 parameters are passed through X0-X7 registers (I pass only 6 arguments by mistake, but it's enough for the maximum of 6 arguments of mmap in the TinyInjector).

#if defined(__aarch64__)
  #define uregs regs
#endif

for(int i = 0; i < argc && i < 6; ++i) {
  regs.uregs[i] = args[i];
}

The Linker Namespace Bypass

As I wrote in my review of the Android linker namespace, the caller of dlopen can be forged.

By pointing the X30 register (LR) to a code section of any shared library, which namespace has wide privileges, we make the dynamic linker of target process to apply desired namespace rules to dlopen call.

Currently, I would like to load shared library from /data/local/tmp (/sdcard can be acceptable option as well). dlopen of the most of the Android applications is limited about to load shared libraries from such untrusted place. But the linker namespace of /system/lib/libRS.so allows to load shared libraries from any place of /data.

Steps:

  1. Load the start of the libRS.so's code segment in the target process.
    #if defined(__aarch64__)
    #define VNDK_LIB_PATH  "/system/lib64/libRS.so"
    #else
    #define VNDK_LIB_PATH  "/system/lib/libRS.so"
    #endif
    
    long vndk_return_addr = GetModuleBaseAddr(pid, VNDK_LIB_PATH);
    
  2. Point the return address to libRS.so:
    long ret = CallRemoteFunctionFromNamespace(pid, function_addr, vndk_return_addr, params, 2);
    
    Inside the function:
    regs.ARM_lr = return_addr;
    
  3. Perform the remote call:
    PtraceSetRegs(pid, &regs);
    

When dlopen call finishes, I watch the same effect as a pointing LR to NULL: get the control back after the fault and restoring the running context by setting stored registers.

SELinux Label for Injection Library

The final step is to overcome SELinux that denies to mmap a shared library from /data. Use the same trick with a label as before:

chcon -v u:object_r:apk_data_file:s0 /data/local/tmp/libinject.so