Flash CTF – Signal Signal Little Star

Challenge Overview

Signal Signal Little Star is a reversing challenge built around nested UNIX signals. The binary always has two active signal handlers. Sending either one decodes an embedded, encoded payload that either:

  • installs the next pair of handlers (internal node), or
  • prints a message and exits (leaf).

Only one leaf prints the flag; all other leaves produce astrology‑themed “wrong path” messages. The specific signal numbers and the flag path are unknown at the outset.


Initial Analysis

Running the program yields a single, constant intro line and then blocks, waiting for signals. At any moment, exactly two signals are “live.” Triggering a live signal installs the next two (or prints a leaf and exits). Internally:

  • Each node carries both of its children’s encoded payloads.
  • At a leaf, the message is printed using async‑safe I/O and the process exits.
  • The correct path and per‑stage signal pairs are unknown a priori and must be discovered.

Implications for solving:

  • We must discover the currently active pair at each stage rather than guessing.
  • We should drive the program, stage by stage, exploring until we reach the flag leaf.

Static Recon

I started with the usual triage:

  • file shows an x86‑64 ELF, dynamically linked, stripped.
  • strings is very sparse. Besides the intro line (“The cosmos hum softly…”) there are familiar C library symbols in the PLT: sigactionsigaltstackpausewrite, and friends.
  • readelf -r and objdump -d reveal calls to sigaltstack@plt and multiple sigaction@plt invocations with the same sa_flags bit pattern. The handler address passed to those calls is consistent across pairs of installs.

Two details jump out immediately:

  • The program sets up an alternate signal stack (sigaltstack) early, which is a tell for non‑trivial handlers.
  • It repeatedly calls sigaction in pairs: back‑to‑back installs whose only difference is the signal number. That strongly suggests “choose left or right” behavior driven by signals.

Next, I hunted for “data blobs”:

  • In .rodata I found large byte arrays with no obvious ASCII. They’re not referenced by write@plt directly, but they are referenced near the signal handler. The code around them does a tight byte‑wise transform before branching on the first decoded byte. That’s likely an in‑place decode followed by a small “interpreter.”

At this point, I had a good hypothesis: each handler decodes an opaque payload and either installs the next pair of handlers (node) or prints a message (leaf).


Dynamic Recon

Before fully reverse‑engineering the handler, I wanted to confirm the “pair at a time” model from the outside, without symbols. Two quick ways:

  1. Interpose sigaction with LD_PRELOAD and log every install where SA_SIGINFO is set (that’s the hallmark of the “live” choice handlers in this binary).
  2. strace -e trace=rt_sigaction -f ./binary will also show the signal numbers being installed if you don’t want to compile anything, but it’s noisier.

I went with an interposer because it gives precise control and a clean log. It showed a very clear pattern:

  • At idle, nothing new is installed.
  • When I send one of the two “live” signals, the process either exits (leaf) or immediately installs two more signals with SA_SIGINFO. Rinse and repeat.

This validates the two‑way branching tree.


Reversing the Handler

In the disassembly of the installed handler, there’s a small decode loop before any branching:

  • Each byte from a source region is loaded, modified by a per‑index additive offset, then XORed with a single‑byte key.
  • After decoding, the first byte decides the “record type”.

Stripped down pseudo‑code looks like this (names inferred from behavior):

// derived from handler disassembly
uint8_t *decode(const uint8_t *enc, size_t n) {
    uint8_t *out = malloc(n + 1);
    for (size_t i = 0; i < n; i++) {
        uint8_t b = enc[i];
        // observed pattern: xor with a constant, then undo a small index-based add
        b ^= 0xA7;
        b -= (uint8_t)((i * 3 + 7) & 0xFF);
        out[i] = b;
    }
    out[n] = 0;
    return out;

Right after decoding, the handler switches on the first byte:

  • If it’s the ASCII for 'N' (0x4E), the code reads a couple of 32‑bit little‑endian fields, then copies two encoded child blobs somewhere safe and installs the next two handlers using the two 32‑bit integers as signal numbers.
  • If it’s 'L' (0x4C), the code reads a one‑byte “kind” and a 16‑bit length, then writes the message buffer to stdout and exits. When kind == 1, this is the flag leaf (you’ll see the flag printed). Otherwise, it’s just one of the astrology taunts.

Concretely, the decoded in‑memory format is:

Node:
  0x00: 'N'
  0x01..0x04: a_sig (u32, little endian)
  0x05..0x08: b_sig (u32, little endian)
  0x09..0x0C: left_len (u32, le)
  0x0D..     : left_blob (left_len bytes, still ENCODED for storage)
  ... after left_blob:
  next 4: right_len (u32, le)
  next   : right_blob (right_len bytes, ENCODED)

Leaf:
  0x00: 'L'
  0x01: kind (0 = wrong, 1 = flag)
  0x02..0x03: msg_len (u16, le)
  0x04..     : msg (msg_len bytes, raw)

The “gotcha” is that child blobs are stored encoded; the handler decodes the appropriate blob on demand when you send that branch’s signal. That explains why only one of the two child payloads is consumed per signal.

This is plenty to confirm the model and to build a solver without any hardcoded signals.


Approach

  1. Build a tiny sigaction interposer and preload it via LD_PRELOAD. We log signal installations that carry SA_SIGINFO (these are the “live” handlers the challenge uses).
  2. Write a solver that:
    • starts the target with LD_PRELOAD,
    • treats the interposer’s log as a stream: every two new SA_SIGINFO installs form the current stage’s pair,
    • sends the chosen signal,
    • waits for the next two installs (the next stage’s pair),
    • recurses in a depth‑first search until a leaf is reached; if it’s not the flag, backtrack and try the sibling branch.

This avoids any hardcoded signal names or paths, and it’s robust across arbitrary depths.

DFS strategy in practice

  • Represent a path as a list of choices [0, 1, 0, ...], where 0 means “send the first signal of the current pair” and 1 means “send the second.”
  • For each candidate path, start a fresh process, then:
    1. Wait for the first pair (two SA_SIGINFO installs) and send the corresponding choice.
    2. If the program exits, check the last line printed:
      • matches flag → done,
      • otherwise → dead end (wrong leaf).
    3. If two new installs appear instead, that’s an internal node; continue with the next choice in the path.
  • DFS execution loop:
    • Start from the empty path [].
    • If a path reaches an internal node (still running, next pair observed), push both children onto a stack: path+[1] and path+[0] (order doesn’t matter).
    • If it reaches a wrong leaf, discard and backtrack.
    • Stop immediately when a flag leaf is found.
  • Because the tree is finite (the program exits at leaves and never creates cycles), DFS will enumerate all branches and is guaranteed to find the unique flag path.

Building the Oracle (sigaction logger)

#define _GNU_SOURCE
#include <dlfcn.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static int (*real_sigaction)(int, const struct sigaction*, struct sigaction*);
static FILE *logf = NULL;

__attribute__((constructor))
static void siglog_init(void) {
	const char *p = getenv("SIGLOG_FILE");
	if (!p || !*p) p = "siglog.txt";
	logf = fopen(p, "w");
	setvbuf(logf, NULL, _IOLBF, 0);
	real_sigaction = dlsym(RTLD_NEXT, "sigaction");
}

__attribute__((destructor))
static void siglog_fini(void) {
	if (logf) fclose(logf);
}

int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact) {
	if (!real_sigaction) real_sigaction = dlsym(RTLD_NEXT, "sigaction");
	if (logf && act) {
		int has_info = (act->sa_flags & SA_SIGINFO) ? 1 : 0;
		if (has_info) {
			fprintf(logf, "SET %d SA_SIGINFO=1\n", signum);
			fflush(logf);
		}
	}
	return real_sigaction(signum, act, oldact);
}
  • Hooks sigaction via LD_PRELOAD.
  • Logs only installs with SA_SIGINFO to SIGLOG_FILE (defaults to siglog.txt).
  • Produces lines like: SET <signum> SA_SIGINFO=1.

Build:

  • gcc -shared -fPIC -O2 -Wall -Wextra -o siglog.so siglog.c -ldl

Solver

import os
import re
import signal
import subprocess
import sys
import time
from pathlib import Path

FLAG_RE = re.compile(rb"MetaCTF\{[^}]+\}", re.IGNORECASE)

def parse_pairs(log_path: Path):
	if not log_path.exists():
		return []
	try:
		raw = log_path.read_bytes()
	except Exception:
		return []
	lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
	set_lines = [ln for ln in lines if b"SA_SIGINFO=1" in ln]
	nums = []
	for ln in set_lines:
		parts = ln.split()
		if len(parts) >= 3 and parts[0] == b"SET":
			try:
				nums.append(int(parts[1]))
			except Exception:
				pass
	pairs = []
	for i in range(0, len(nums), 2):
		if i + 1 < len(nums):
			pairs.append((nums[i], nums[i+1]))
	return pairs

def read_sa_siginfo_lines(log_path: Path):
	if not log_path.exists():
		return []
	try:
		raw = log_path.read_bytes()
	except Exception:
		return []
	lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
	nums = []
	for ln in lines:
		if b"SA_SIGINFO=1" not in ln:
			continue
		parts = ln.split()
		if len(parts) >= 3 and parts[0] == b"SET":
			try:
				nums.append(int(parts[1]))
			except Exception:
				pass
	return nums

def _safe_decode(b: bytes) -> str:
	try:
		return b.decode("utf-8", errors="replace")
	except Exception:
		return repr(b)

def run_path(binary: str, path_choices, sleep_between=0.05):
	log_path = Path("siglog.txt")
	if log_path.exists():
		try:
			log_path.unlink()
		except Exception:
			pass
	env = os.environ.copy()
	env["LD_PRELOAD"] = os.path.abspath("siglog.so")
	env["SIGLOG_FILE"] = str(log_path)

	# Merge stderr into stdout so we don't miss messages if they were printed there
	proc = subprocess.Popen([binary], stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, text=False)
	pid = proc.pid
	pairs_observed = []
	sa_count = 0
	try:
		# Wait for root pair: need two SA_SIGINFO installs
		for _ in range(200):
			if proc.poll() is not None:
				break
			if len(read_sa_siginfo_lines(log_path)) - sa_count >= 2:
				break
			time.sleep(0.01)
		nums = read_sa_siginfo_lines(log_path)
		if len(nums) - sa_count >= 2:
			pairs_observed.append((nums[sa_count], nums[sa_count + 1]))
			sa_count += 2

		for depth, choice in enumerate(path_choices):
			# Ensure we have the pair for this depth (collect new installs if needed)
			if depth >= len(pairs_observed):
				for _ in range(200):
					if proc.poll() is not None:
						break
					nums = read_sa_siginfo_lines(log_path)
					if len(nums) - sa_count >= 2:
						pairs_observed.append((nums[sa_count], nums[sa_count + 1]))
						sa_count += 2
						break
					time.sleep(0.01)
			if proc.poll() is not None:
				out = proc.communicate()[0]
				if FLAG_RE.search(out):
					return "flag", out, pairs_observed
				return "wrong", out, pairs_observed
			curr_pair = pairs_observed[depth]
			sig_to_send = curr_pair[choice]
			os.kill(pid, sig_to_send)
			time.sleep(sleep_between)

		# After sending all decisions, either next pair appears (internal node) or process exits (leaf)
		for _ in range(200):
			if proc.poll() is not None:
				break
			# If internal node, we expect exactly two more SA installs
			if len(read_sa_siginfo_lines(log_path)) - sa_count >= 2:
				return "continue", b"", pairs_observed
			time.sleep(0.01)
		# If still running and no new pair appeared, treat as internal node
		if proc.poll() is None:
			return "continue", b"", pairs_observed
		out = proc.communicate()[0]
		if FLAG_RE.search(out):
			return "flag", out, pairs_observed
		return "wrong", out, pairs_observed
	finally:
		try:
			proc.kill()
		except Exception:
			pass

def dfs_solve(binary: str, sleep_between=0.05, verbose=True):
	stack = [[]]
	seen = set()
	tried = []
	while stack:
		path = stack.pop()
		t = tuple(path)
		if t in seen:
			continue
		seen.add(t)
		#if verbose:
		#	print(f"[TRY] path={path}")
		status, out, pairs_observed = run_path(binary, path, sleep_between=sleep_between)
		full = _safe_decode(out).strip()
		lines = [ln for ln in full.splitlines() if ln.strip()]
		# Drop the constant intro line if present to surface actual leaf output
		intro = "The cosmos hum softly. Seek the right constellation by signaling."
		lines = [ln for ln in lines if ln != intro]
		msg = lines[-1] if lines else full
		if status == "flag":
			if verbose:
				print(f"[FLAG] path={path} pairs={pairs_observed}")
			sys.stdout.buffer.write(out)
			print(f"\n[+] Path: {path}")
			if tried:
				print("\n[SUMMARY] Tried paths before success:")
				for rec in tried:
					print(f"  - path={rec['path']} status={rec['status']} msg={rec['msg'][:120]!r}")
			return True
		if status == "continue":
			# Explore left first (0), then right (1)
			if verbose:
				print(f"[NODE] path={path} -> deeper (pairs so far: {pairs_observed})")
			stack.append(path + [1])
			stack.append(path + [0])
			tried.append({"path": path.copy(), "status": "node", "msg": ""})
		else:
			# wrong leaf
			if verbose:
				print(f"[LEAF-WRONG] path={path} pairs={pairs_observed} output_last_line={msg!r}")
				#if full and full != msg:
				#	print(f"[LEAF-WRONG-FULL] {full!r}")
			tried.append({"path": path.copy(), "status": "wrong", "msg": msg})
	return False

if __name__ == "__main__":
	if len(sys.argv) < 2:
		print(f"Usage: {sys.argv[0]} /path/to/signal_signal_little_star")
		sys.exit(2)
	binary = sys.argv[1]
	sleep_between = 0.05
	if len(sys.argv) >= 3:
		try:
			sleep_between = float(sys.argv[2])
		except Exception:
			pass
	ok = dfs_solve(binary, sleep_between=sleep_between, verbose=True)
	sys.exit(0 if ok else 1)

Some key details:

  • Stream pairing: The solver reads siglog.txt as a growing stream and keeps a cursor. For each stage, it waits for exactly two new SA_SIGINFO installs and treats those as the current pair. After sending the selected signal, it again waits for two new installs to identify the next stage’s pair. This prevents “pair drift” and eliminates mis‑pairing.
  • No premature timeouts: If the process is still running and no new installs appeared yet, the solver treats the state as “continue” instead of forcing a read with a short timeout. This keeps exploration stable even on slow machines.
  • Clear output: The solver logs each path explored, the pairs observed at each stage, and filters out the constant intro line when presenting leaf output. This makes it easy to see real leaf messages.
  • Parameter‑agnostic: The solver never hardcodes signal sets. It discovers them at runtime, so it remains valid even if the generator used repeated pairs or deep trees (which for this challenge it does not).

Expected behavior:

  • You’ll see per‑path logs like:
    • “node” entries when a stage transitions (two new installs observed),
    • “leaf‑wrong” with the last non‑intro line of output when a wrong leaf is hit,
    • flag output when the correct leaf is hit, followed by a brief summary.

This avoids guessing which signals are active. By always waiting for exactly two fresh SA_SIGINFO installs per stage, the solver stays synchronized with the program’s state machine. Running each candidate path in a fresh process keeps things simple: we don’t need to model state; we just observe and react.


Closing Thoughts

From the player’s perspective, there are three steps to own this: prove there are always two live signals; prove each one unpacks the next stage or a message; and automate the exploration based on what the binary actually does. Once you see the 'N'/'L' record split in the handler and notice the two sigaction calls per hop, the rest is just letting the stars align—one signal at a time.