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:
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.
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:
Implications for solving:
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: sigaction, sigaltstack, pause, write, 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:
sigaltstack) early, which is a tell for non‑trivial handlers.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”:
.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).
Before fully reverse‑engineering the handler, I wanted to confirm the “pair at a time” model from the outside, without symbols. Two quick ways:
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).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:
SA_SIGINFO. Rinse and repeat.This validates the two‑way branching tree.
In the disassembly of the installed handler, there’s a small decode loop before any branching:
Stripped down pseudo‑code looks like this (names inferred from behavior):
// derived from handler disassemblyuint8_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:
'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.'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.
sigaction interposer and preload it via LD_PRELOAD. We log signal installations that carry SA_SIGINFO (these are the “live” handlers the challenge uses).LD_PRELOAD,SA_SIGINFO installs form the current stage’s pair,This avoids any hardcoded signal names or paths, and it’s robust across arbitrary depths.
[0, 1, 0, ...], where 0 means “send the first signal of the current pair” and 1 means “send the second.”[].path+[1] and path+[0] (order doesn’t matter).#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);}
sigaction via LD_PRELOAD.SA_SIGINFO to SIGLOG_FILE (defaults to siglog.txt).SET <signum> SA_SIGINFO=1.Build:
gcc -shared -fPIC -O2 -Wall -Wextra -o siglog.so siglog.c -ldlimport osimport reimport signalimport subprocessimport sysimport timefrom pathlib import PathFLAG_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 pairsdef 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 numsdef _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: passdef 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 Falseif __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:
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.Expected behavior:
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.
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.