Neven Marić
Hidden Investigations CTF 2026

OSINTHidden Investigations CTF 2026

chillin!!!

Identify a Petra-by-Night photo with no EXIF data, then brute-force ~1700 what3words squares around the Treasury to find the exact 3 m cell the flag expects.

  • osint
  • geolocation
  • what3words
  • brute-force
  • web-scraping
  • rate-limiting

What this one teaches

  • Treating a what3words location flag as a bounded brute-force problem once the landmark is identified.
  • Working around API key failures by scraping a public converter form instead.
  • Handling rCTF rate limits programmatically — reading the timeLeft field and sleeping it out.

Task description

This looks scary!! Am I tripping?? Where is this actually? yo!, get me the name and exact three words for this location.

  • Category: OSINT (hard)
  • Flag format: hiCTF{city_country_word1.word2.word3} — the what3words address of the exact spot.

Challenge

One JPEG and the prompt above. The flag combines the city and country with a what3words address.

A large crowd sits on the floor of a deep red-sandstone canyon at twilight among paper-bag candles, with lit Bedouin stalls and a coloured-light stage visible at the far end between the cliff walls

Approach

Metadata check

The file is bare JFIF: no EXIF, no GPS, no XMP, no comment, no trailing data. Everything has to come from the picture.

Scene identification

A large crowd sits on the floor of a deep red-sandstone canyon at twilight among paper-bag candles, with lit Bedouin stalls and a coloured-light stage at the far end. Yandex and Google Lens both return “Petra by Night”, the candle-lit event held in front of the Treasury (Al-Khazneh) in Petra, Jordan. No exact copy of the image exists online; the 19 Petra-by-Night photos on Wikimedia Commons were checked and none match, so there is no geotagged original to read coordinates from.

Camera position

The Treasury facade is not in frame. The camera looks across the seated audience toward a narrow gap between the cliffs with the stalls on the right, i.e. it stands past the forecourt looking back toward the Siq mouth, with the Treasury behind and to the side. That puts the shooting position somewhere in a roughly 100 m stretch of canyon, far wider than one 3 m what3words square.

Canonical squares fail

The obvious answers were all rejected: porters.chuckling.shelled (the Treasury on what3words and Google Maps), the Wikipedia and OpenStreetMap coordinates of Al-Khazneh and the Siq, the Wikidata coordinates of Petra, the Nominatim “Petra By Night” POI in Wadi Musa, and the square the what3words place search gives for “Petra, Jordan”. City-name variants (Petra_Jordan, wadi_musa_jordan, wadimusa_jordan) were rejected for those squares too.

Brute force over the squares

Since the location is a bounded patch of canyon, every 3 m square in it can be enumerated and submitted. The official what3words API was tried first but the key returned 402 then 401 InvalidKey, so I scraped the coordinates-to-what3words form on geocachingtoolbox.com instead — no key needed.

The enumerator samples a lat/lng grid at 3 m spacing around the Treasury, ordered by distance from the centre, and converts each point to three words:

#!/usr/bin/env python3
"""
Enumerate every what3words square in a box around a centre point, using the
coordinates-to-what3words form on geocachingtoolbox.com (no API key needed).

Usage:
  python w3w_grid_gct.py --center 30.32214 35.45175 --radius 25 -o squares.csv
"""
import argparse
import csv
import html
import math
import os
import re
import sys
import time
import urllib.parse
import urllib.request

URL = "https://www.geocachingtoolbox.com/index.php?page=w3w&status=result"
SQUARE_M = 3.0
M_PER_DEG_LAT = 111_320.0
WORDS_RE = re.compile(r"what3words address:</td><td>([^<]+)</td>")


def meters_to_deg(lat_deg, metres):
    return metres / M_PER_DEG_LAT, metres / (M_PER_DEG_LAT * math.cos(math.radians(lat_deg)))


def convert(lat, lng, lang="en", retries=3):
    data = urllib.parse.urlencode({"w3wMethod": "c2w3w", "w3w4": f"{lat:.7f} {lng:.7f}", "w3wLanguage": lang}).encode()
    req = urllib.request.Request(URL, data=data, headers={"User-Agent": "Mozilla/5.0 (w3w-grid)"})
    for attempt in range(retries):
        try:
            with urllib.request.urlopen(req, timeout=30) as r:
                page = r.read().decode("utf-8", "replace")
            m = WORDS_RE.search(page)
            if not m:
                raise RuntimeError("no words in response")
            return html.unescape(m.group(1)).strip()
        except Exception:
            if attempt == retries - 1:
                raise
            time.sleep(3 * (attempt + 1))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--center", type=float, nargs=2, metavar=("LAT", "LNG"), required=True)
    ap.add_argument("--radius", type=float, default=25, help="half-width of box in metres")
    ap.add_argument("--lang", default="en")
    ap.add_argument("--delay", type=float, default=0.3, help="seconds between requests")
    ap.add_argument("-o", "--out", default="squares.csv")
    a = ap.parse_args()

    clat, clng = a.center
    dlat, dlng = meters_to_deg(clat, SQUARE_M)
    n = int(math.ceil(a.radius / SQUARE_M))
    points = [(clat + i * dlat, clng + j * dlng, math.hypot(i, j))
              for i in range(-n, n + 1) for j in range(-n, n + 1)]
    points.sort(key=lambda p: p[2])

    seen = {}
    if os.path.exists(a.out):
        with open(a.out, newline="", encoding="utf-8") as f:
            for row in csv.DictReader(f):
                seen[row["words"]] = (float(row["lat"]), float(row["lng"]))

    new_file = not os.path.exists(a.out)
    with open(a.out, "a", newline="", encoding="utf-8") as f:
        w = csv.writer(f)
        if new_file:
            w.writerow(["words", "lat", "lng"])
        for k, (lat, lng, _) in enumerate(points, 1):
            if any(abs(lat - la) < dlat * 0.9 and abs(lng - ln) < dlng * 0.9
                   for la, ln in seen.values()):
                continue
            words = convert(lat, lng, a.lang)
            if words not in seen:
                seen[words] = (lat, lng)
                w.writerow([words, f"{lat:.7f}", f"{lng:.7f}"])
                f.flush()
            time.sleep(a.delay)


if __name__ == "__main__":
    main()

A 25 m radius gave 361 unique squares; 60 m radius gave 1681.

The submitter fires each flag at the rCTF API through curl (the site’s Cloudflare rejects Python’s urllib signature outright), sleeps out badRateLimit responses by reading the timeLeft field in milliseconds, and logs every tried flag so re-runs skip it:

#!/usr/bin/env python3
"""
Submit hiCTF{<prefix>word1.word2.word3} for every square in a CSV produced by
w3w_grid_gct.py to the rCTF instance, stopping at the first goodFlag.

Usage:
  python submit_w3w.py squares.csv --prefix petra_jordan_ --delay 1.5
"""
import argparse
import csv
import json
import os
import subprocess
import sys
import time

BASE = "https://ctf.hiddeninvestigations.net/api/v1"
CHALL = "95b1fd8a-1b11-4179-815f-949182374807"


def post(path, body, auth=None):
    cmd = ["curl", "-s", "-X", "POST", f"{BASE}{path}",
           "-H", "Content-Type: application/json",
           "--data-binary", json.dumps(body)]
    if auth:
        cmd += ["-H", f"Authorization: Bearer {auth}"]
    out = subprocess.run(cmd, capture_output=True, text=True, timeout=30).stdout
    try:
        return json.loads(out)
    except json.JSONDecodeError:
        return {"kind": "httpError", "raw": out[:200]}


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("csv")
    ap.add_argument("--prefix", default="petra_jordan_")
    ap.add_argument("--delay", type=float, default=1.5)
    ap.add_argument("--tried", default="tried_flags.txt")
    ap.add_argument("--token", required=True, help="rCTF team token")
    a = ap.parse_args()

    auth = post("/auth/login", {"teamToken": a.token})["data"]["authToken"]
    tried = set()
    if os.path.exists(a.tried):
        with open(a.tried, encoding="utf-8") as fh:
            tried = {l.strip() for l in fh if l.strip()}
    with open(a.csv, newline="", encoding="utf-8") as fh:
        rows = list(csv.DictReader(fh))
    with open(a.tried, "a", encoding="utf-8") as log:
        for i, row in enumerate(rows, 1):
            flag = f"hiCTF{{{a.prefix}{row['words']}}}"
            if flag in tried:
                continue
            while True:
                r = post(f"/challs/{CHALL}/submit", {"flag": flag}, auth)
                kind = r.get("kind")
                if kind != "badRateLimit":
                    break
                wait = r.get("data", {}).get("timeLeft", 5000) / 1000 + 0.5
                time.sleep(wait)
            log.write(flag + "\n")
            log.flush()
            if kind == "goodFlag":
                print(f"SOLVED: {flag}  ({row['lat']},{row['lng']})")
                return
            if kind in ("badAlreadySolvedChallenge", "badUnknownUser",
                        "badToken", "httpError"):
                return
            time.sleep(a.delay)


if __name__ == "__main__":
    main()

Result

Sweeps in the order run:

Prefix Area Squares Outcome
petra_jordan_ 25 m box 361 all rejected
Petra_Jordan_ 25 m box 362 all rejected
wadi_musa_jordan_ 25 m box 362 all rejected
petra_jordan_ 25 m to 60 m ring 631 of 1320 goodFlag

The accepted square polite.solos.watchmaker is centred near 30.32244, 35.45131, about 40 m north-west of the Treasury facade in the Outer Siq. That is consistent with the camera position read from the photo, just outside the forecourt proper.

Lessons

  • A “location” flag at what3words resolution is a brute-force problem once the landmark is known: a few hundred to a couple of thousand squares cover any plausible camera position, and the rCTF rate limit only slows it to ~3 s each.
  • Order the candidates by distance from the landmark but do not stop at the forecourt; the hit was 40 m out, beyond the first 361 squares.
  • Try the prefix variants on a small set before spending an hour on a wide sweep; here the lowercase site name was right all along and only the square was missing.
The flagRevealhiCTF{petra_jordan_polite.solos.watchmaker}

This CTF was possibly solved, and the writeup most likely written by an LLM. Apologies for inconsistencies.