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:
fileshows an x86‑64 ELF, dynamically linked, stripped.stringsis 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 -randobjdump -dreveal calls tosigaltstack@pltand multiplesigaction@pltinvocations with the samesa_flagsbit 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
sigactionin 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
.rodataI found large byte arrays with no obvious ASCII. They’re not referenced bywrite@pltdirectly, 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:
- Interpose
sigactionwithLD_PRELOADand log every install whereSA_SIGINFOis set (that’s the hallmark of the “live” choice handlers in this binary). strace -e trace=rt_sigaction -f ./binarywill 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. Whenkind == 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
- Build a tiny
sigactioninterposer and preload it viaLD_PRELOAD. We log signal installations that carrySA_SIGINFO(these are the “live” handlers the challenge uses). - Write a solver that:
- starts the target with
LD_PRELOAD, - treats the interposer’s log as a stream: every two new
SA_SIGINFOinstalls 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.
- starts the target with
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:
- Wait for the first pair (two SA_SIGINFO installs) and send the corresponding choice.
- If the program exits, check the last line printed:
- matches flag → done,
- otherwise → dead end (wrong leaf).
- 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]andpath+[0](order doesn’t matter). - If it reaches a wrong leaf, discard and backtrack.
- Stop immediately when a flag leaf is found.
- Start from the empty path
- 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
sigactionviaLD_PRELOAD. - Logs only installs with
SA_SIGINFOtoSIGLOG_FILE(defaults tosiglog.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.txtas a growing stream and keeps a cursor. For each stage, it waits for exactly two newSA_SIGINFOinstalls 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.