MiscHidden Investigations CTF 2026
The Foxglove Trace
Reconstruct a scoring rule from a debug log — movement cutoff, signal-strength weighting, and trust weights — then apply it to a CSV export to find the highest-scoring access point.
What this one teaches
- Deriving a threshold from boundary cases in a debug log — step_delta 40 is dropped, 41 is kept, so the cutoff is > 40.
- Verifying the scoring formula against known subtotals before applying it to the full dataset.
Task description
A hobbyist had a quiet obsession: figuring out, without asking anyone, which unknown place they kept drifting back to on autopilot. Their idea was to mine two signals their phone already recorded — step-count spikes and the strength of nearby Wi-Fi access points — on the theory that a place that truly matters shows up as you moving around near the same access point, over and over.
They never finished it. All that’s left is a handful of files pulled off the old project folder: some scratch notes, a tiny config, one exported scan, and the log of the single run that actually worked.
No server, no service, nothing to connect to. This is pure offline forensics. Reconstruct the person’s scoring rule from what the files imply — the movement cutoff that separates “walking” from “sitting still”, and how a per-access-point score is built from signal strength and the trust weights — then apply it to the full export and name the place they kept returning to. The loudest access point is a trap; so is the one that appears most often. Get the rule right and exactly one place rises to the top.
The derived place name is the key to the flag. Submit it as
hiCTF{PlaceName}, with the name spelled as the access point appears in the files.
Approach
The four files
notes.txt: explains the scoring logic — only trusted APs fromaps.confare considered, stationary readings (lowstep_delta) are discarded, and each kept reading contributes signal strength scaled by the AP’s trust weight.aps.conf: lists the 4 trusted access points and their weights:FoxgloveCoffee_Guest=3 HomePlex_5G=2 CafeDelMundo=2 NETGEAR-8841=1run.log: a debug run (tracker.py --verbose --only CafeDelMundo,HomePlex_5G) with kept/dropped annotations and subtotals:-- CafeDelMundo (w=2) -- signal=62 step_delta= 40 -> stationary, dropped signal=58 step_delta= 41 -> moving, kept signal=60 step_delta= 58 -> moving, kept signal=64 step_delta= 77 -> moving, kept subtotal = 364 -- HomePlex_5G (w=2) -- signal=92 step_delta= 5 -> stationary, dropped signal=90 step_delta= 11 -> stationary, dropped signal=87 step_delta= 44 -> moving, kept (7 more rows dropped) subtotal = 174scan_export.csv: 33 rows oftimestamp,ap_name,signal,step_delta.
Reconstructing the scoring rule
Movement cutoff: step_delta = 40 is marked stationary, dropped, while step_delta = 41 is moving, kept. The condition is step_delta > 40.
Subtotal formula: For CafeDelMundo (w=2), kept signals are 58 + 60 + 64 = 182, times weight 2 = 364 — matches. For HomePlex_5G (w=2), only kept signal is 87, times 2 = 174 — matches. So: score(AP) = weight * sum(signal for rows where step_delta > 40).
Solution
import csv
weights = {
"FoxgloveCoffee_Guest": 3,
"HomePlex_5G": 2,
"CafeDelMundo": 2,
"NETGEAR-8841": 1,
}
scores = {ap: 0 for ap in weights}
with open("scan_export.csv", newline="") as f:
for row in csv.DictReader(f):
ap = row["ap_name"]
signal = int(row["signal"])
step_delta = int(row["step_delta"])
if ap in weights and step_delta >= 41:
scores[ap] += signal * weights[ap]
for ap, score in sorted(scores.items(), key=lambda x: x[1], reverse=True):
print(f"{ap}: {score}")
Results:
| Access point | Calculation | Score |
|---|---|---|
| FoxgloveCoffee_Guest | (68 + 71 + 66 + 73 + 70) x 3 | 1044 |
| CafeDelMundo | (60 + 64 + 58) x 2 | 364 |
| NETGEAR-8841 | (80 + 78 + 82) x 1 | 240 |
| HomePlex_5G | 87 x 2 | 174 |
FoxgloveCoffee_Guest has the highest score, giving the flag.
The flagReveal
hiCTF{FoxgloveCoffee_Guest}This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.