· 3 min read

The Crash With No Trace

I spent a weekend hunting a crash that left nothing in logcat or tombstone. Here's the trick behind it, and how I finally reproduced it on my laptop.

  • #reverse-engineering
  • #android
  • #signals

A friend sent me a crash log from an Android app that was empty. Not short — empty. No stack trace, no fault address, no register dump. Just a process that died. The tombstone was 0 bytes. Logcat had nothing.

The app ran fine on most devices. On the ones that mattered, it would just vanish.

What “no trace” usually means

When an Android process dies, the kernel gives you two things. A line in logcat, and a tombstone file under /data/tombstones/. Both are written by signal handlers that the OS installs. If a signal handler itself fails before it can finish writing, you get garbage. If it fails recursively, you get nothing.

That’s the trick: install a custom signal handler that re-faults.

static void trap_handler(int sig, siginfo_t *info, void *ucontext) {
    volatile int *bad = (volatile int *)0xDEADBEEF;
    *bad = 42;          // re-fault inside the handler
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_sigaction = trap_handler;
    sa.sa_flags     = SA_SIGINFO;
    sigaction(SIGABRT, &sa, NULL);
    sigaction(SIGSEGV, &sa, NULL);
    raise(SIGABRT);
}

When raise(SIGABRT) fires, the kernel jumps to trap_handler. The handler immediately dereferences 0xDEADBEEF, which is itself a SIGSEGV. The SIGSEGV handler is the same function, so it re-faults again. Eventually the kernel’s signal-delivery stack exhausts and the process dies without ever writing a usable trace.

Reproducing it on a laptop

I tried this on WSL with two short C programs. Same compiler flags, same GDB. The first crashes cleanly:

#0  main () at normal_crash.c:14
    p = 0x0

The second — the one with the trap handler — gives you this:

#0  trap_handler (sig=11) at double_fault.c:45
#1  <signal handler called>
#2  trap_handler (sig=6)  at double_fault.c:45
#3  <signal handler called>
#4  __pthread_kill_implementation
#5  __pthread_kill_internal
#6  __GI___pthread_kill
#7  __GI___raise (sig=6)
#8  main (argc=1, argv=...) at double_fault.c:68

The original fault — raise(SIGABRT) at the bottom of the stack — is buried. The top of the trace points into the handler, not the caller. A reverser looking at this would think the bug lives in trap_handler, chase it for hours, and never get to the actual trigger.

That’s the whole point. The crash is not a bug. It’s a defensive wall.

Why it works

A normal crash handler runs in a clean context: the faulting frame is on the stack, registers are intact, you can call backtrace() and get a sensible answer. A handler that re-faults corrupts that context before the first byte of trace data is written. The OS tries to deliver the new signal, pushes another frame, jumps to the handler again, the handler faults again, and so on until the kernel gives up.

The tombstone writer is just a signal handler. If the signal-handler machinery itself is broken, the tombstone never lands. Same with logcat — it goes through a socket on a separate thread, but if the process is being torn apart by recursive faults, that thread doesn’t get to run either.

The take

Every anti-analysis trick I’ve seen reduces to the same principle: don’t make analysis impossible, make it expensive. A torn backtrace, a missing core dump, a stack frame that lies — each one adds twenty minutes of “wait, what?” to anyone trying to understand the crash. Twenty minutes per sample, ten samples per day, that’s a workday burned for free.

The defense against it is mostly patience. Run the binary in an emulator you control. Hook raise before it fires. Patch out the trap handler. Or — if you can — just read the assembly and skip the crash entirely.

Code

The two 30-line programs that produce the backtraces above — double_fault.c, normal_crash.c, and a run-demo.sh that runs both side by side — live at github.com/UxHarshit/double-fault-demo. gcc and gdb are the only dependencies.

thanks for reading ~say hi →