Foreword

In the previous post, I used dlsym to fork the process and avoid the debugger to be able to attach it. While reading comments, people highlighted that I could use follow-fork mode in LLDB and GDB.

It pushed me to try other mechanisms and see how I could bypass them using reverse-engineering techniques. My attempt is to see if there is a more robust approach.

Bye-bye fork!

The first thing I could do to avoid follow-fork mode is to avoid to fork!

But where can I go?

When I shared the previous post on Hacker News, someone in the comment section mentioned ptrace.

I already saw this somewhere. Let’s do a quick refresh by typing man 2 ptrace.

Hello ptrace

This system library provides a utility function which allows one process to control another one.

As the manual is not always pleasant to read, I’ll try to summarize what I found essential.

int ptrace(int request, pid_t pid, caddr_t addr, int data);

There are four arguments, but the first one (request) is the most important as it will determine if we need others or not.

There are a bunch of request we could perform, but the one we’ll focus on is PT_DENY_ATTACH!

This request is used by the traced process to deny future traces by its parent. For this specific request the remaining three arguments are ignored.

Wait a minute! It means that the parent, LLDB or GDB in this case are using ptrace them self. Let’s verify!

Does LLDB uses PT_DENY_ATTACH?

It could be intresting to see which mechanism LLDB uses to attach the process.

Looking directly in the code could be cumbersome. Let’s write a basic example, enable LLDB logs so we could see what is happening.

#include <stdio.h>

#include <sys/ptrace.h>

#include <unistd.h>


int main() {
  printf("Run in pid %d\n", getpid());
  printf("Check if debugger is attached\n");
  if (ptrace(31, 0, 0, 0) < 0) {
    perror("ptrace");
    return 1;
  }
  printf("Debugger is not attached!\n");
  printf("Run... \n");
  sleep(1000);
  return 0;
}

If you need, I wrote a CMake file here to build this translation unit.

Now, we have to use the LLDB_DEBUGSERVER_LOG_FILE environment variable to specify a path where logs should be written.

$ export LLDB_DEBUGSERVER_LOG_FILE=./log.txt

Now, we can start our program (in the same terminal)…

❯ lldb ./ptrace_anti_debug
(lldb) target create "./ptrace_anti_debug"
Current executable set to '/Users/tonygo/temp/anti-debug/ptrace/build/ptrace_anti_debug' (arm64).

.. and run it:

(lldb) run
Process 58335 launched: '/Users/tonygo/temp/anti-debug/ptrace/build/ptrace_anti_debug' (arm64)
Run in pid 58335
Check if debugger is attached
Process 58335 exited with status = 45 (0x0000002d)
(lldb) quit

The first thing I note is that the process exit status is 45. I’ll keep that in mind!

Now I’ll try to filter logs with ptrace string:

❯ cat log.txt | grep ptrace(
[LaunchAttach] (58336) About to ptrace(PT_ATTACHEXC, 58335)...
[LaunchAttach] (58336) Completed ptrace(PT_ATTACHEXC, 58335) == 0
[LaunchAttach] (58336) Done napping after ptrace(PT_ATTACHEXC)'ing
[...]

I can confirm now that LLDB uses ptrace. Surprisingly, LLDB succeed in attaching the pid 58335 which is the process ID of our program.

A few lines later in log.txt I find this line:

Attach succeeded, ready to debug.

Not so surprising in the end as at this stage, I did not type the run LLDB command. So there is no ptrace(PT_DENY_ATTACH, ... call so far.

I can imagine that there is two steps here:

  • ptrace is called with PT_ATTACHEXC (in LLDB), which could set a flag somewhere
  • ptrace is called with PT_DENY_ATTACH (in my code), which check if the variable/flag and exit the program accordingly

Now I’ll try to reverse engineer the ptrace system library to verify my hypothesis.

How ptrace workd under the hood

I’ll start from XNU sources, but querying ptrace.

There are a bunch of files, but there are two that interest me:

First, I noticed that the mach_process file is part of the BDS kernel. While the first one seems to be related to the libsyscall we are using in user land. Let’s start with that one.

ptrace syscall

We have a file full of assembly instructions … The first thing I observe is that we have different implementation of the syscall, I’ll try to read the arm64 one:

#elif defined(__arm64__)

MI_ENTRY_POINT(___ptrace)           ; #1
	MI_GET_ADDRESS(x9,_errno)         ; #2
	str		wzr, [x9]                   ; #2
	SYSCALL_NONAME(ptrace, 4, cerror) ; #3
	ret
	
#else

This is not obvious at first, but syscalls are, most of the time, wrappers around assembly. When we call the C API, behind the scene it will execute these instructions.

For instance, you can compile asm along C code using CMake and call the assembly directly from C.

In the assembly snippet above, the most important is that

  • #1: It just adds the ___ptrace label to this snippet and expose it for the linker (this logic is hidden in the MI_ENTRY_POINT pre-processor)
  • #2: It get the errno adress, set it to zero, this is a way to ensure that the state is clean before calling the syscall.
  • #3: It calls ptrace syscall, by calling svc with the appropriate syscall number.

Note: when I took a look at symbols in my executable using nm -g if found _ptrace and no ___ptrace. It pushes me to check at the libsystem_kernel.dylib to find

    ❯ nm -g /Users/tony/dyld-cache/usr/lib/system/libsystem_kernel.dylib | grep ptrace
    000000018040eedc T ___ptrace
    000000018040eedc T _ptrace

It seems that the compiler generated both :)

Now let’s take a look at the code executed in the kernel mode.

ptrace kernel code

This is second file I shared earlier: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/mach_process.c#L118

Finally a bit of C haha! The first intresting part is here (I cleaned the code a bit for readability)

	if (uap->req == PT_ATTACHEXC) {        // #1
		uap->req = PT_ATTACH;                // #2
		tr_sigexc = 1;
	}
	if (uap->req == PT_ATTACH) {           // #2
		int             err, cb_err;

		err = kauth_authorize_process(kauth_cred_get(), KAUTH_PROCESS_CANTRACE,
		    t, (uintptr_t)&cb_err, 0, 0);

		if (err == 0) {
			/* it's OK to attach */
			proc_lock(t);
			SET(t->p_lflag, P_LTRACED);        // #3

This is the parf of the code trigged by doing ptrace(PT_ATTACHEXC, ...) (like LLDB does)

  • #1: The execution is branching for PT_ATTACHEXC
  • #2: Then we re-set the request to PT_ATTACH and branch to the next if
  • #3: Finally the p_lflag is set to P_LTRACED

Now when I decide to run my executable in LLDB ptrace(PT_DENY_ATTACH, ...) is called:

	if (uap->req == PT_DENY_ATTACH) {             // #1
		proc_lock(p);
		if (ISSET(p->p_lflag, P_LTRACED)) {         // #2
			proc_unlock(p);
			KERNEL_DEBUG_CONSTANT(BSDDBG_CODE(DBG_BSD_PROC, BSD_PROC_FRCEXIT) | DBG_FUNC_NONE,
			    proc_getpid(p), W_EXITCODE(ENOTSUP, 0), 4, 0, 0);
			exit1(p, W_EXITCODE(ENOTSUP, 0), retval); // #3

			thread_exception_return();
			/* NOTREACHED */
		}
		SET(p->p_lflag, P_LNOATTACH);
		proc_unlock(p);

		return 0;
	}
  • #1: The execution is branching for PT_DENY_ATTACH
  • #2: Then we branch if p_lflag is set to P_LTRACED (which is the case)
  • #3: Finally, we exit with ENOTSUP status code (which is 45)

Awesome! This explains why we saw:

Process 58335 exited with status = 45 (0x0000002d)

I know that curiosity is a bad trait, especially after hours of research, but I would like to see how it works, if I attach LLDB while my program is already running…

Attach the process during its run

First we need, to write logs into another file:

$ export LLDB_DEBUGSERVER_LOG_FILE=./log2.txt

Let’s run the program directly this time:

❯ ./ptrace_anti_debug
Run in pid 20292
Check if debugger is attached
Debugger is not attached!
Run...

… and in another terminal:

❯ lldb -p 20292
(lldb) process attach --pid 20292
error: attach failed: lost connection lldb -p 20292

You can quit LLDB then:

(lldb) quit

Now, this is time to observe the content of log2.txt file.

Inspect LLDB log

First, I’ll use grep to see what if we have something related to ptrace.

cat log2.txt | grep ptrace
[LaunchAttach] (20295) About to ptrace(PT_ATTACHEXC, 20292)...

When I open the log file, to see what we have before/after:

[LaunchAttach] (58094) About to ptrace(PT_ATTACHEXC, 57618)...
114 +0.000025 sec [e2ee/1703]: MachTask::ExceptionThread ( arg = 0x145809268 ) starting thread...
[EOL]

We don’t really have an exit code this time! Let’s take a look at the source code!

In this case, ptrace(PT_DENY_ATTACH, ...) is called before ptrace(PT_ATTACHEXC, ...).

The first call of ptrace will set this flag:

SET(p->p_lflag, P_LNOATTACH);

Then when the second call of ptrace is happening:

/* not allowed to attach, proper error code returned by kauth_authorize_process */
if (ISSET(t->p_lflag, P_LNOATTACH)) {
	psignal(p, SIGSEGV);
}
goto out;

SIGSEGV means “Signal Segmentation Fault”! It probably explain why we have a different result!

Now when I think about it, the documentation said:

If the process is currently being traced, it will exit with the exit status of ENOTSUP; otherwise, it sets a flag that denies future traces. An attempt by the parent to trace a process which has set this flag will result in a segmentation violation in the parent.

I think I have a good understanding about the internals of ptrace and how it works with LLDB.

But wait! It did not help to bypass it.

Bypass ptrace

I’ll use Hopper to disassemble my binary and patch it!

First, I look at symbols on the left pane:

symbols of my executable in hopper

In the main functions I directly see this:

0000000100003e9c         ldr        w3, [sp, #0x10 + var_8]
0000000100003ea0         movz       w0, #0x1f
0000000100003ea4         mov        x1, x3
0000000100003ea8         movz       x2, #0x0                          
0000000100003eac         bl         imp___stubs__ptrace

The line movz w0, #0x1f is setting the first register to 0x1f aka 31 aka PT_DENY_ATTACH.

What if I patch the binary and set w0 to 0 aka PT_TRACE_ME, which it is used to declare that the process expects to be traced by its parent. By doing this we ensure that:

  • The ptrace call is still valid
  • It won’t block any debugger (as it’s expected to be traced)

The final result is:

0000000100003e9c         ldr        w3, [sp, #0x10 + var_8]
0000000100003ea0         movz       w0, #0x0
0000000100003ea4         mov        x1, x3
0000000100003ea8         movz       x2, #0x0                          
0000000100003eac         bl         imp___stubs__ptrace

Now, I’ll click on File > Produce New Executable, and rename it ptrace_anti_debug_patch!

I would like to check how LLDB will handle it that time…

❯ lldb ./ptrace_anti_debug_patch
(lldb) target create "./ptrace_anti_debug_patch"
Current executable set to '.../anti-debug/ptrace/build/ptrace_anti_debug_patch' (arm64).

… suspense!

(lldb) run
Process 1027 launched: '/Users/tonygo/temp/anti-debug/ptrace/build/ptrace_anti_debug_patch' (arm64)
Run in pid 1027
Check if debugger is attached
Debugger is not attached!
Run...

Boom!

Conclusion

While ptrace offers a straightforward way to implement anti-debugging, it’s relatively easy to spot and bypass. In the next post, we’ll explore more sophisticated approaches make both detection and bypassing significantly more challenging for reverse engineers.