Debugging & power tools

gdb, ltrace, core dumps, jq, ripgrep, fzf.

Advanced14 min · lesson 16 of 17

A log line tells you what a program chose to announce. It says nothing about the parts it never mentions: the lock it is blocked on, the pointer that was zero, the library call that failed while nobody was checking. So when a service hangs, crashes, or misbehaves and the logs go quiet, you drop below the application and read the process itself. The tools here do that. Some freeze a running program and read its memory. Some record every call it makes. Some cut through the flood of output afterward. There is a defender's reason to know them cold: the same tools that find your deadlock are the ones an attacker uses to read secrets out of a process, and the settings that stop the attacker are the ones that will block your debugger too.

A service is misbehaving and the logs don't explain it — which tool?
What is the symptom?
It's hung
gdb -p + thread apply all bt
read every thread's stack; two threads each stuck on a lock the other holds = a deadlock
It crashed
coredumpctl gdb
open the stored core dump; see the fault line and the argument that was null
You need to see what it calls
strace (syscalls) / ltrace (library calls)
two doors: the kernel boundary vs the libc boundary, no source needed
You're drowning in output
jq / rg / fd / fzf
filter JSON, search fast, find files, fuzzy-pick interactively

Reading a live process with gdb

gdb (the GNU Debugger, a program that inspects and controls another running program) is like a mechanic who can freeze a running engine in place, then walk around and study every spinning part exactly where it stopped. It attaches to a target by its PID (process ID, the number the kernel gives every running program) and reads that program's memory: its variables, its call stack, and every thread inside it.

When a service hangs, the question that pays off is simple. What is every thread waiting for? A thread (one independent line of execution inside a process) that is stuck is usually blocked on a lock that another thread is holding and not releasing. gdb can print the backtrace (the chain of function calls that led to the current spot) of every thread at once, so one command shows you the whole traffic jam.

~/secopslog — bash
$ # a hung service (PID 812): freeze it, print every thread's stack, then let it go sudo gdb -p 812 -batch -ex "thread apply all bt" 2>/dev/null
Thread 3 (Thread 0x7f8c1a7fc640 (LWP 815) "worker"): #0 __futex_abstimed_wait_common64 (futex_word=0x561f0d2a4f60, expected=2, ...) at ./nptl/futex-internal.c:57 #1 __GI___lll_lock_wait (futex=0x561f0d2a4f60, private=0) at ./nptl/lowlevellock.c:49 #2 ___pthread_mutex_lock (mutex=0x561f0d2a4f60) at ./nptl/pthread_mutex_lock.c:93 #3 flush_metrics () at metrics.c:88 #4 worker_loop () at worker.c:141 Thread 1 (Thread 0x7f8c1b2f4740 (LWP 812) "worker"): #0 __futex_abstimed_wait_common64 (futex_word=0x561f0d2a5120, expected=2, ...) at ./nptl/futex-internal.c:57 #1 __GI___lll_lock_wait (futex=0x561f0d2a5120, private=0) at ./nptl/lowlevellock.c:49 #2 ___pthread_mutex_lock (mutex=0x561f0d2a5120) at ./nptl/pthread_mutex_lock.c:93 #3 collect_metrics () at metrics.c:52 #4 main () at worker.c:30

Read the addresses, not the function names alone. Thread 3 (LWP 815, where LWP means light-weight process, the kernel's name for a thread) is asleep in __lll_lock_wait on mutex 0x561f0d2a4f60, called from flush_metrics. Thread 1 is asleep the same way, but on a different mutex, 0x561f0d2a5120, called from collect_metrics. Two threads, two locks, and neither one will wake. Line the two code paths up in metrics.c and the trap shows itself: collect_metrics took 0x561f0d2a4f60 first and is now stuck waiting for 0x561f0d2a5120, while flush_metrics took 0x561f0d2a5120 first and is stuck waiting for 0x561f0d2a4f60. Each thread is sitting on the exact key the other one needs. That is a deadlock caused by a lock-order inversion, and gdb walked you to both source lines without a single log message and without rebuilding anything. This is your first move on any frozen daemon (a program that runs quietly in the background as a service).

Attaching stops the target while you look (the danger note below spells that out). If you want the stacks without holding a busy service still, two lighter moves help. gcore 812 writes a snapshot of the process's memory to a file and lets the process keep running, so you study the copy offline. And eu-stack -p 812 (from the elfutils package) grabs a backtrace with a far shorter pause than a full gdb session, which is gentler on something that is still serving traffic.

Core dumps: the crash caught in amber

A core dump is a flight recorder for a crash. The instant a program dies from a fault, say it reads through a null pointer, the kernel (the core of the operating system that controls the hardware) can write the entire contents of that program's memory to a file, the core dump, before it lets the process go. You open the wreck later and see exactly where and why it went down. No racing to attach in time, no reproducing the bug. The crash is frozen for you.

Core dumps are often switched off, because they can be large and can hold secrets. ulimit -c unlimited turns them on for your shell. On a systemd machine, though, the kernel usually does not write a file at all; it hands the crash to a helper program instead. That policy lives in a kernel setting called core_pattern, and reading it tells you what will happen on the next crash.

~/secopslog — bash
$ cat /proc/sys/kernel/core_pattern
|/usr/lib/systemd/systemd-coredump %P %u %g %s %t %c %h

The leading pipe symbol is the tell. Instead of a filename, the kernel is told to run a program and stream the core into it. Here that program is systemd-coredump, which stores the dump, records who crashed and why, and makes it retrievable with coredumpctl. The %P %u %g %s codes pass it the crashing PID, user, group, and signal number. (On a stock Ubuntu box you may see apport in that slot instead; install the systemd-coredump package to get the coredumpctl workflow below.)

~/secopslog — bash
$ coredumpctl list
TIME PID UID GID SIG COREFILE EXE SIZE Fri 2026-07-17 14:22:31 UTC 812 1000 1000 SIGSEGV present /usr/local/bin/worker 1.1M
$ # open the crash in gdb, straight from the stored dump coredumpctl gdb 812
PID: 812 (worker) Signal: 11 (SEGV) Timestamp: Fri 2026-07-17 14:22:31 UTC (6min ago) Command Line: /usr/local/bin/worker --serve Executable: /usr/local/bin/worker Core was generated by `/usr/local/bin/worker --serve'. Program terminated with signal SIGSEGV, Segmentation fault. #0 0x000055c3a1b2f4e2 in parse_header (buf=0x0) at http.c:214 214 return buf->len; (gdb) bt #0 parse_header (buf=0x0) at http.c:214 #1 handle_request (fd=7) at http.c:139 #2 worker_loop () at worker.c:141 (gdb)

buf=0x0 is the whole story. Something called parse_header with a null pointer (an address of zero, which points at nothing), and line 214 tried to read a field through it. SIGSEGV (signal 11, a segmentation fault, the kernel killing a process for touching memory it is not allowed to) followed. The backtrace shows the path that got there: a request came in, handle_request ran, parse_header choked. For a defender this reads two ways at once. A repeatable crash on attacker-controlled input like an HTTP header is often the front half of a memory-corruption exploit, and the core dump is your evidence for exactly which input reached which line.

Core dumps hold live secrets
A core dump is a copy of everything the program had in memory when it died: decrypted keys, passwords, session tokens, other users' data. systemd stores them under /var/lib/systemd/coredump, readable by root, and they are a prize for anyone who gets a foothold. Treat them like the secrets inside them: restrict access, move them to a controlled place for analysis, and clear them when you are done. Storage= and MaxUse= in /etc/systemd/coredump.conf control where they land and how much space they may eat.

Two levels of eavesdropping: strace and ltrace

A program talks to the outside world through two doors. One door opens onto the kernel: every time the program wants to read a file, open a network socket (an endpoint for sending and receiving data over the network), or ask the operating system for memory, it makes a system call (a request across the boundary into the kernel) through that door. strace, covered in the tracing lesson, sits at that door and logs every crossing. The second, inner door opens onto the program's libraries, the shared code it was built against, like libc (the standard C library that provides the basics: string handling, memory allocation, and so on). ltrace sits at that inner door and logs the calls into those libraries.

That library view is often closer to what the program means than the raw system calls beneath it. You see the malloc, the strlen, the library's own functions by name, which sit closer to intent than the read and write calls they eventually become. The -c flag adds up where the calls and the time went, which turns a vague "it's slow" into an algorithm you can name.

~/secopslog — bash
$ ltrace -c ./worker
% time seconds usecs/call calls function ------ ----------- ----------- --------- -------------------- 68.21 0.412339 0 1049283 strlen 20.11 0.121553 1 88211 malloc 6.44 0.038902 0 88211 free 3.10 0.018701 2 9004 memcpy 2.14 0.012884 14 901 getenv ------ ----------- ----------- --------- -------------------- 100.00 0.604379 1235610 total

Over a million strlen calls dominate a run that made only nine thousand memcpy calls. That lopsided count is the fingerprint of code that measures the same string over and over, classically a strlen sitting inside a loop over a buffer that keeps growing, so the cost climbs with the square of the size. You found the hot spot without opening the source and without attaching a profiler (a tool that samples a running program to show where it spends its time).

Sifting the output: jq, ripgrep, fd, fzf

Every tool above produces a flood of text or JSON. The last group is about surviving that flood. These are newer command-line programs that do the jobs grep and find do, faster and with better defaults, plus one that adds interactive search. None ship by default, so on a fresh host: apt install jq ripgrep fd-find fzf. Put them on every box you operate.

jq is a query language for JSON (JavaScript Object Notation, the curly-brace text format that logs and APIs speak in), the way awk is a query language for columns of text. Modern logs are JSON, and journalctl -o json hands you the systemd journal as one JSON object per line. jq filters it, reshapes it, and pulls out only the fields you care about.

~/secopslog — bash
$ # pull only error-and-worse journal entries out as clean, labelled text journalctl -o json -b | jq -r 'select((.PRIORITY|tonumber) <= 3) | "\(.SYSLOG_IDENTIFIER): \(.MESSAGE)"'
sshd: error: kex_exchange_identification: Connection closed by remote host kernel: EXT4-fs error (device sda1): ext4_lookup:1855: inode #524290: comm nginx: deleted inode referenced systemd: nginx.service: Main process exited, code=exited, status=1/FAILURE kernel: Out of memory: Killed process 4127 (java) total-vm:8123400kB, anon-rss:7984120kB

Priority in the journal runs 0 (emergency) to 7 (debug), so <= 3 keeps errors, critical, alerts, and emergencies and drops the noise. One line filters a full boot down to the four things that actually went wrong: a failed SSH handshake, a filesystem error, a service that died, and the out-of-memory killer shooting a process. That is a triage view no grep over plain text gives you cleanly, because the priority is a real field, not a word buried in a message.

ripgrep (rg) is grep rebuilt for speed. It searches directories on its own with no -r, skips binary files and anything in .gitignore by default, and uses every CPU core. On a big log tree the difference is minutes versus a fraction of a second, and --stats shows you the damage.

~/secopslog — bash
$ sudo rg -n --stats "connection refused" /var/log
/var/log/app/worker.log 1423:2026-07-17T09:14:02Z ERROR dial tcp 10.0.3.12:5432: connect: connection refused /var/log/syslog 88213:Jul 17 09:14:02 web01 worker[812]: upstream 10.0.3.12:5432 connection refused 2 matches 2 matched lines 2 files contained matches 617 files searched 0.074 seconds spent searching 0.081 seconds

fd is find with defaults that match how you actually search. fd -e conf . /etc/nginx walks that tree for files ending in .conf and skips hidden and ignored paths automatically. One naming gotcha to know cold: on Debian and Ubuntu the binary is installed as fdfind, because the name fd was already taken by another package, so people alias it back to fd. fzf is a fuzzy finder: pipe any list into it and you get an interactive, type-to-narrow selector. Bound to Ctrl-R in your shell (the fzf package sets this up), it replaces the blind reverse-history search with one you can see and refine, and history | fzf does the same on demand.

~/secopslog — bash
$ # on Debian/Ubuntu this binary is 'fdfind' (from: apt install fd-find) fd -e conf . /etc/nginx
/etc/nginx/nginx.conf /etc/nginx/conf.d/default.conf

Before you reach for gdb, strace, or ltrace on a shared host, check one setting. It decides whether the kernel will even let you attach.

~/secopslog — bash
$ cat /proc/sys/kernel/yama/ptrace_scope
1
Attaching freezes the target, and hardening can block it
gdb -p, strace, and ltrace all work through ptrace (the kernel interface one process uses to inspect and control another). ptrace STOPS the target while it is attached and steps through it, so attaching to a busy production service can freeze it long enough to miss health checks and trigger an outage. A quick batch backtrace on an already-hung process is usually safe. On a healthy one, reach instead for gcore, or for the tools that watch without stopping the target: perf (Linux's built-in sampling profiler) and the tracers built on eBPF (extended Berkeley Packet Filter, a kernel feature that runs small, safe tracing programs inside the kernel). The value you just read, kernel.yama.ptrace_scope, is the other half of the story. Set to 1 (the Ubuntu and Debian default), it blocks attaching to any process that is not a descendant of yours, which is exactly what stops malware from reading secrets out of your ssh-agent or another login session. Loosening it with sysctl -w kernel.yama.ptrace_scope=0 (sysctl is the command that reads and writes kernel tunables) to get your debugger working also strips that protection from everyone on the box. Set it back to 1 when you are done.
Quick check
01On a shared host you cannot attach gdb to a service running under your own account, and root is not the missing piece. cat /proc/sys/kernel/yama/ptrace_scope prints 1. What is that 1 telling you, and what do you hand over if you run sysctl -w kernel.yama.ptrace_scope=0?
Incorrect — ptrace_scope is a policy switch, not a counter. The kernel does not ration tracers, so waiting for a slot never frees anything up.
Correct — That is the Yama rule and the real cost of turning it off: the guard that stops one hijacked process from mining another for credentials goes with it.
Incorrect — Symbols decide how readable a backtrace is once you are inside. The kernel makes the attach decision before symbols ever come into it.
Incorrect — Attaching is what stops the target, so you do not have to. Stopping it yourself still leaves the Yama check to be answered when the tracer attaches.
02You are handed a compiled binary with no source and told to watch what it does. strace and ltrace both work on it, but they sit at different doors. Which description matches what each one records?
Incorrect — That swaps the pair. Allocation and string work is library traffic, which is ltrace's side of the fence, not strace's.
Incorrect — They tap different boundaries, so you get two genuinely different call streams. Speed is not what separates them.
Incorrect — Neither one needs source. Working on a binary nobody will rebuild for you is exactly the situation both were made for.
Correct — The library names sit closer to what the code meant, while the kernel calls show what it finally did, so pick the door that matches your question.
03thread apply all bt on a hung daemon shows Thread 3 asleep in __lll_lock_wait on mutex 0x561f0d2a4f60 called from flush_metrics, and Thread 1 asleep the same way on 0x561f0d2a5120 called from collect_metrics. What have you found, and what did gdb change to find it?
Incorrect — A thread parked in __lll_lock_wait is asleep, not burning cycles, and gdb never hands a target extra speed.
Incorrect — Contention clears when a holder lets go. Here each holder is itself waiting, and two different mutex addresses are in play, so the scheduler has nothing to unstick.
Correct — Lining the two addresses up across the two call paths in metrics.c is what names it a lock-order inversion, and observation alone got you there.
Incorrect — Nothing in these stacks points at allocation, and gdb reads a target's memory rather than repairing it for you.

Try this

Work through “Sifting the output: jq, ripgrep, fd, fzf” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: core dumps hold live secrets. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related