Add universal crash backtrace via _Unwind_Backtrace

Replace the glibc-only execinfo.h backtrace with _Unwind_Backtrace
from GCC's libgcc, which works on glibc and musl, static-pie and
dynamic, across all supported architectures.

Key design decisions:
- __ehdr_start (GNU ld linker symbol) gives the PIE load base
  reliably on all targets, replacing dl_iterate_phdr which does not
  enumerate the main executable consistently on musl static builds
- SOURCE_ROOT compile definition strips the build machine prefix so
  backtraces show project-relative paths (src/signals.c:42)
- dladdr() resolves dynamic symbol names from .dynsym for stripped
  shared-library frames; /proc/self/maps provides the library name
  as fallback when no symbol is exported
- Each frame is logged as a single line:
    #N  func_name    src/file.c:line   (resolved)
    #N  0xADDRESS    (libc.so.6  sym+off)  (unresolved)

New subcommands:
- crash:     triggers a deterministic SIGSEGV via mmap(PROT_NONE)
             (not UB, not elided by the optimizer, not caught by
             sanitizers) to validate the crash handler end-to-end
- backtrace: prints a live backtrace without crashing, for manual
             inspection on a given build or platform

CI: add check_crash() to test/arch_test.sh, called unconditionally
for every architecture to verify the handler fires and a backtrace
is produced.

Bug fixes:
- removePID(): guard config.files.pid.v.s != NULL before fopen()
  to prevent a secondary SIGSEGV when config was never loaded
  (e.g. crash/backtrace subcommands)
- signal_handler(): fflush(stdout) immediately after generate_backtrace()
  so the output is preserved even if cleanup() faults on uninitialized
  state before exit() can flush stdio

Signed-off-by: Dominik <dl6er@dl6er.de>
This commit is contained in:
Dominik
2026-03-20 05:47:11 +01:00
parent e16ce86411
commit 227b69573d
7 changed files with 292 additions and 88 deletions
+12 -5
View File
@@ -357,14 +357,21 @@ if(HAVE_RANDOM_H)
target_compile_definitions(core PRIVATE USE_GETRANDOM)
endif()
check_include_file("execinfo.h" HAVE_EXECINFO_H)
if(HAVE_EXECINFO_H)
message(STATUS "Building FTL with unwind support: YES")
target_compile_definitions(core PRIVATE USE_BACKTRACE)
# <unwind.h> is part of GCC's libgcc — available on ALL targets (musl+glibc, static+dynamic)
# _Unwind_Backtrace() is already linked via -lgcc/-static-libgcc; no find_library needed
check_include_file("unwind.h" HAVE_UNWIND_H)
if(HAVE_UNWIND_H)
target_compile_definitions(core PRIVATE USE_UNWIND)
message(STATUS "Building FTL with _Unwind_Backtrace support: YES")
else()
message(STATUS "Building FTL with unwind support: NO")
message(STATUS "Building FTL with _Unwind_Backtrace support: NO")
endif()
# Embed the source root so crash backtraces can show project-relative paths
# (e.g. "src/args.c" instead of "/home/user/FTL/src/args.c").
target_compile_definitions(core PRIVATE SOURCE_ROOT="${CMAKE_SOURCE_DIR}/")
option(USE_READLINE "Build FTL with readline support, if available" ON)
+37
View File
@@ -76,6 +76,9 @@
#include "config/inotify.h"
// get_all_supported_ciphersuites()
#include "webserver/webserver.h"
// mmap(), PROT_NONE, MAP_PRIVATE, MAP_ANONYMOUS — used for intentional crash
// test
#include <sys/mman.h>
// defined in dnsmasq.c
extern void print_dnsmasq_version(const char *yellow, const char *green, const char *bold, const char *normal);
@@ -253,6 +256,40 @@ void parse_args(int argc, char *argv[])
if(argc == 2 && strcmp(argv[1], "sigrtmin") == 0)
exit(sigrtmin());
// Intentional crash test — used in CI to verify the crash handler and
// backtrace machinery work correctly.
// We use mmap(PROT_NONE) + a write to produce SIGSEGV SEGV_ACCERR.
// This is NOT undefined behaviour, NOT elided by the optimiser, and NOT
// intercepted by ASan or UBSan — unlike a raw null/invalid-pointer cast.
// handle_signals() and init_backtrace() are called here explicitly
// because parse_args() runs before main() sets them up.
if(argc == 2 && strcmp(argv[1], "crash") == 0)
{
cli_mode = true;
log_ctrl(false, true);
config.misc.addr2line.v.b = true; // Enable addr2line — config not loaded in subcommand context
handle_signals();
void *addr = mmap(NULL, 4096u, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if(addr != MAP_FAILED)
{
volatile char *ptr = (volatile char *)addr;
*ptr = 'x'; // Write to PROT_NONE page — SIGSEGV SEGV_ACCERR
}
raise(SIGSEGV); // Fallback: mmap should never fail in practice
exit(EXIT_FAILURE);
}
// Generate a backtrace without crashing — useful for manual inspection
// of the backtrace output on a given build/platform.
if(argc == 2 && strcmp(argv[1], "backtrace") == 0)
{
cli_mode = true;
log_ctrl(false, true);
config.misc.addr2line.v.b = true; // Enable addr2line — config not loaded in subcommand context
generate_backtrace();
exit(EXIT_SUCCESS);
}
// If the binary name is "sqlite3" (e.g., symlink /usr/bin/sqlite3 -> /usr/bin/pihole-FTL),
// we operate in drop-in mode and consume all arguments for the embedded SQLite3 engine
// Also, we do this if the first argument is a file with ".db" ending
+5
View File
@@ -156,6 +156,11 @@ void savePID(void)
*/
static void removePID(void)
{
// Config may not have been loaded (e.g. crash/backtrace subcommands),
// in which case no PID file was ever written — nothing to remove.
if(config.files.pid.v.s == NULL)
return;
FILE *f = NULL;
// Open file for writing to overwrite/empty it
if((f = fopen(config.files.pid.v.s, "w")) == NULL)
+5
View File
@@ -55,6 +55,11 @@ int main (int argc, char *argv[])
// Obtain log file location
getLogFilePath(true);
// Store binary path and PIE load base address for crash-time backtrace.
// Must be called before handle_signals() so bin_path is populated before
// the first possible signal.
init_backtrace(argc > 0 ? argv[0] : NULL);
// Parse arguments
// We run this also for no direct arguments
// to have arg{c,v}_dnsmasq initialized
+210 -81
View File
@@ -9,9 +9,13 @@
* Please see LICENSE file for your rights under this license. */
#include "FTL.h"
#if defined(USE_BACKTRACE)
// backtrace(), backtrace_symbols()
#include <execinfo.h>
// Universal unwind backtrace via GCC's libgcc — works on glibc AND musl, static AND dynamic
#if defined(USE_UNWIND)
# include <unwind.h>
# include <limits.h> // PATH_MAX
# include <inttypes.h> // SCNxPTR — portable sscanf format for uintptr_t
# include <sys/mman.h> // mmap() — used for the intentional crash test subcommand
# include <dlfcn.h> // dladdr() — dynamic symbol lookup from .dynsym
#endif
#include "signals.h"
// logging routines
@@ -34,6 +38,192 @@ static volatile pid_t mpid = 0;
static time_t FTLstarttime = 0;
volatile int exit_code = EXIT_SUCCESS;
// Binary path stored by init_backtrace() — signal-handler-safe static buffer,
// never reallocated, safe to read from any context including signal handlers
#if defined(USE_UNWIND)
static char bin_path[PATH_MAX] = { 0 };
#endif
#if defined(USE_UNWIND)
// PIE load base address — set once at startup by init_backtrace() using the
// __ehdr_start linker symbol (GNU ld / lld / mold). __ehdr_start points to the
// ELF header in memory, which equals the ASLR slide for PIE binaries and the
// static load address for non-PIE. Either way: file_vaddr = runtime_addr -
// exe_load_addr is exactly what addr2line expects. This is a linker symbol, not
// a libc function, so it is reliable on glibc AND musl, static AND dynamic —
// unlike dl_iterate_phdr, whose behaviour for static executables varies across
// musl versions and may leave exe_load_addr == 0.
static uintptr_t exe_load_addr = 0;
// Provided by the linker for every ELF executable (GNU ld, lld, mold, gold).
extern const char __ehdr_start;
#endif // USE_UNWIND
// Initialize the backtrace subsystem.
// Must be called early in main() (before handle_signals()) so that bin_path
// and exe_load_addr are ready when the first crash handler fires.
void init_backtrace(const char *argv0)
{
#if defined(USE_UNWIND)
// /proc/self/exe gives the canonical absolute path even when argv[0] is
// a relative path, a bare binary name, or a symlink.
ssize_t len = readlink("/proc/self/exe", bin_path, sizeof(bin_path) - 1u);
if(len > 0)
bin_path[len] = '\0';
else if(argv0 != NULL)
{
strncpy(bin_path, argv0, sizeof(bin_path) - 1u);
bin_path[sizeof(bin_path) - 1u] = '\0';
}
// Cache the PIE load base address for addr2line offset adjustment.
// __ehdr_start is a linker symbol pointing at the ELF header in memory,
// which equals the ASLR load base for PIE binaries.
exe_load_addr = (uintptr_t)&__ehdr_start;
#else
// Unused, cannot generate backtraces right now on non-gcc targets —
// this silences a warning about unused parameters in this case
(void)argv0;
#endif // USE_UNWIND
}
#if defined(USE_UNWIND)
// State carried through each _Unwind_Backtrace callback invocation
struct unwind_state {
void **frames;
int count;
int max;
};
// Callback invoked by _Unwind_Backtrace for each frame on the call stack.
// Signal-handler-safe: no heap allocation, no stdio, no locks.
static _Unwind_Reason_Code unwind_callback(struct _Unwind_Context *ctx, void *arg)
{
struct unwind_state *state = (struct unwind_state *)arg;
if(state->count >= state->max)
return _URC_END_OF_STACK;
uintptr_t ip = _Unwind_GetIP(ctx);
if(ip == 0)
return _URC_END_OF_STACK;
// Subtract 1 so the address points at the call instruction rather than
// the return address — addr2line then resolves the correct source line.
state->frames[state->count++] = (void *)(ip - 1u);
return _URC_NO_REASON;
}
#endif // USE_UNWIND (unwind_callback)
#if defined(USE_UNWIND)
// Look up which /proc/self/maps entry contains addr and copy the basename
// of the mapped file (e.g. "libc.so.6", "[vdso]") into buf.
// buf is left empty when the address is not found or has no path.
static void find_mapping_name(const void *addr, char *buf, const size_t buflen)
{
FILE *maps = fopen("/proc/self/maps", "r");
if(maps == NULL)
return;
const uintptr_t a = (uintptr_t)addr;
char line[512];
while(fgets(line, sizeof(line), maps) != NULL)
{
uintptr_t start = 0, end = 0;
char path[256] = { 0 };
// Format: start-end perms offset dev inode [path]
// Use %*s (not %*llx etc.) to avoid the GNU -Wformat= restriction
// that forbids combining the assignment-suppression modifier with a
// length modifier. We only care about start, end, and path.
const int n = sscanf(line, "%" SCNxPTR "-%" SCNxPTR " %*s %*s %*s %*s %255s",
&start, &end, path);
if(n >= 2 && a >= start && a < end && path[0] != '\0')
{
const char *base = strrchr(path, '/');
strncpy(buf, base ? base + 1 : path, buflen - 1u);
buf[buflen - 1u] = '\0';
break;
}
}
fclose(maps);
}
// Log one backtrace frame as a single line.
// Resolved: " #N func_name src/file.c:line"
// Unresolved: " #N 0xADDRESS (reason)"
static void log_frame(const int idx, const void *addr, const void *rel_addr)
{
if(!config.misc.addr2line.v.b)
{
log_info(" #%-2i %p (addr2line disabled via config)", idx, addr);
return;
}
if(bin_path[0] == '\0')
{
log_info(" #%-2i %p (binary path unknown)", idx, addr);
return;
}
char cmd[512];
snprintf(cmd, sizeof(cmd), "addr2line -f -e %s %p", bin_path, rel_addr);
FILE *fp = popen(cmd, "r");
if(fp == NULL)
{
log_info(" #%-2i %p (addr2line not available)", idx, addr);
return;
}
char func[256] = { 0 }, loc[256] = { 0 };
if(fgets(func, sizeof(func), fp) != NULL)
{
char *nl = strchr(func, '\n');
if(nl != NULL) *nl = '\0';
}
if(fgets(loc, sizeof(loc), fp) != NULL)
{
char *nl = strchr(loc, '\n');
if(nl != NULL) *nl = '\0';
}
pclose(fp);
if(strcmp(func, "??") == 0)
{
// addr2line found nothing — the frame is in a shared library or a
// stripped section. Try dladdr() which reads .dynsym, the dynamic
// symbol table present in every shared library (even stripped ones).
Dl_info dl = { 0 };
if(dladdr(addr, &dl) != 0 && dl.dli_sname != NULL)
{
// Library basename (e.g. "libc.so.6")
const char *lib = dl.dli_fname ? strrchr(dl.dli_fname, '/') : NULL;
const char *libname = lib ? lib + 1 : (dl.dli_fname ? dl.dli_fname : "?");
// Byte offset from the nearest preceding symbol
const uintptr_t offset = (uintptr_t)addr - (uintptr_t)dl.dli_saddr;
log_info(" #%-2i %p (%s %s+0x%zx)", idx, addr, libname, dl.dli_sname, (size_t)offset);
}
else
{
// dladdr() gave nothing — fall back to /proc/self/maps for at
// least the library/mapping name ([vdso], [stack], etc.).
char mapping[128] = { 0 };
find_mapping_name(addr, mapping, sizeof(mapping));
if(mapping[0] != '\0')
log_info(" #%-2i %p (%s, no debug info)", idx, addr, mapping);
else
log_info(" #%-2i %p (no debug info)", idx, addr);
}
return;
}
// Strip the compile-time source root to show project-relative paths
// (e.g. "src/signals.c:42" instead of "/home/user/FTL/src/signals.c:42").
const char *display_loc = loc;
#if defined(SOURCE_ROOT)
if(strncmp(loc, SOURCE_ROOT, sizeof(SOURCE_ROOT) - 1u) == 0)
display_loc = loc + sizeof(SOURCE_ROOT) - 1u;
#endif
log_info(" #%-2i %-30s %s", idx, func, display_loc);
}
#endif // USE_UNWIND
volatile sig_atomic_t thread_cancellable[THREADS_MAX] = { false };
const char * const thread_names[THREADS_MAX] = {
"database",
@@ -54,91 +244,26 @@ static char * __attribute__ ((nonnull (1))) getthread_name(char buffer[16])
return buffer;
}
#if defined(USE_BACKTRACE)
static void print_addr2line(const char *symbol, const void *address, const int j, const void *offset)
{
// Only do this analysis for our own binary (skip trying to analyse libc.so, etc.)
if(strstr(symbol, BINARY_NAME) == NULL)
return;
// Find first occurrence of '(' or ' ' in the obtaned symbol string and
// assume everything before that is the file name. (Don't go beyond the
// string terminator \0)
int p = 0;
while(symbol[p] != '(' && symbol[p] != ' ' && symbol[p] != '\0')
p++;
// Compute address cleaned by binary offset
void *addr = (void*)(address-offset);
// Invoke addr2line command and get result through pipe
char addr2line_cmd[256];
snprintf(addr2line_cmd, sizeof(addr2line_cmd), "addr2line %p -e %.*s", addr, p, symbol);
FILE *addr2line = NULL;
char linebuffer[512];
if(config.misc.addr2line.v.b &&
(addr2line = popen(addr2line_cmd, "r")) != NULL &&
fgets(linebuffer, sizeof(linebuffer), addr2line) != NULL)
{
char *pos;
// Strip possible newline at the end of the addr2line output
if ((pos=strchr(linebuffer, '\n')) != NULL)
*pos = '\0';
}
else
{
snprintf(linebuffer, sizeof(linebuffer), "N/A (%p -> %s)", addr, addr2line_cmd);
}
// Log result
log_info("L[%04i]: %s", j, linebuffer);
// Close pipe
if(addr2line != NULL)
pclose(addr2line);
}
#endif // USE_BACKTRACE
// Log backtrace
// Log backtrace to the FTL log.
// Uses _Unwind_Backtrace (GCC libgcc) on all targets — glibc AND musl,
// static-pie AND dynamic, all architectures.
void generate_backtrace(void)
{
// Live backtrace generation is not supported by every C standard library
#if defined(USE_BACKTRACE)
// Try to obtain backtrace. This may not always be helpful, but it is better than nothing
void *buffer[255];
const int calls = backtrace(buffer, sizeof(buffer)/sizeof(void *));
log_info("Backtrace:");
#if defined(USE_UNWIND)
void *frames[128];
struct unwind_state state = { frames, 0, 128 };
_Unwind_Backtrace(unwind_callback, &state);
char ** bcktrace = backtrace_symbols(buffer, calls);
if(bcktrace == NULL)
log_info("Backtrace (%d frames):", state.count);
for(int i = 0; i < state.count; i++)
{
log_warn("Unable to obtain backtrace symbols!");
return;
void *rel = (void *)((uintptr_t)frames[i] - exe_load_addr);
log_frame(i, frames[i], rel);
}
// Try to compute binary offset from backtrace_symbols result
void *offset = NULL;
for(int j = 0; j < calls; j++)
{
void *p1 = NULL, *p2 = NULL;
char *pend = NULL;
if((pend = strrchr(bcktrace[j], '(')) != NULL &&
strstr(bcktrace[j], BINARY_NAME) != NULL &&
sscanf(pend, "(+%p) [%p]", &p1, &p2) == 2)
offset = (void*)(p2-p1);
}
for(int j = 0; j < calls; j++)
{
log_info("B[%04i]: %s", j,
bcktrace != NULL ? bcktrace[j] : "---");
if(bcktrace != NULL)
print_addr2line(bcktrace[j], buffer[j], j, offset);
}
free(bcktrace);
#else
log_info("!!! INFO: pihole-FTL has not been compiled with glibc/backtrace support, not generating one !!!");
#endif // USE_BACKTRACE
log_info("!!! INFO: pihole-FTL has not been compiled with unwinding support, cannot generate backtrace !!!");
#endif
}
/**
@@ -268,6 +393,10 @@ static void __attribute__((noreturn)) signal_handler(int sig, siginfo_t *si, voi
generate_backtrace();
// Flush stdout immediately so the backtrace is visible even if a
// subsequent fault in cleanup() kills the process before exit() runs.
fflush(stdout);
// Print content of /dev/shm
ls_dir("/dev/shm");
+1
View File
@@ -22,6 +22,7 @@ void handle_signals(void);
void handle_realtime_signals(void);
pid_t main_pid(void);
void thread_sleepms(const enum thread_types thread, const int milliseconds);
void init_backtrace(const char *argv0);
void generate_backtrace(void);
int sigtest(void);
int sigrtmin(void);
+22 -2
View File
@@ -93,7 +93,25 @@ check_minimum_glibc_version() {
echo "Minimum glibc version check: OK (${1})"
}
if [[ "${CI_ARCH}" == "linux/amd64" ]]; then
check_crash() {
# Run the intentional-crash subcommand and capture combined stdout+stderr.
# The process exits non-zero (killed by SIGSEGV), so we suppress the error.
output="$(./pihole-FTL crash 2>&1 || true)"
if echo "$output" | grep -q "FTL crashed"; then
if echo "$output" | grep -q "Backtrace ("; then
echo "Crash handler test: OK (crash handler invoked, backtrace generated)"
else
# Handler ran but no backtrace — warn rather than fail.
# This can happen if addr2line is absent or the binary has no debug info.
echo "Crash handler test: WARNING (crash handler invoked, no backtrace)"
fi
else
echo "Crash handler test: FAILED (FTL crash handler was not invoked)"
okay=false
fi
}
if [[ "${CI_ARCH}" == "linux/amd64" || "${CI_ARCH}" == "" ]]; then
if [[ "${STATIC}" == "true" ]]; then
check_machine "ELF64" "Advanced Micro Devices X86-64"
@@ -102,7 +120,7 @@ if [[ "${CI_ARCH}" == "linux/amd64" ]]; then
check_file "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), static-pie linked, with debug_info, not stripped"
else
check_machine "ELF64" "Advanced Micro Devices X86-64"
check_libs "[libgmp.so.10] [libidn2.so.0] [libc.musl-x86_64.so.1]"
check_libs "[libgmp.so.10] [libidn2.so.0] [libgcc_s.so.1] [libc.musl-x86_64.so.1]"
check_file "ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-musl-x86_64.so.1, with debug_info, not stripped"
fi
@@ -168,6 +186,8 @@ else
fi
check_crash
if [[ "${okay}" == "false" ]]; then
echo "Binary checks failed"
exit 1