Гесс вот (Guess what)
Гесс вот (Guess what)
Platform: bug-makers | Category: Misc | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-05-29 | Status: Solved Techniques: position_oracle_recovery, predicted_candidate_ordering, recover_then_replay
Summary
Task: TCP guessing game that leaks the 0-indexed position of the first mismatching character of a secret base64 string built from a random hex token, the client IP, and a live UTC timestamp. Solution: abuse the position oracle to recover the secret character-by-character with predicted candidates, then replay the exact recovered string instead of recomputing the time-dependent value (which drifts at the seconds field).
Гесс вот (Guess what) — bug-makers (BugCTF 2026)
Description
По ту сторону сидит любитель игры «Угадай, что я загадал». Цель очевидна - угадать. Удачи!
English: "On the other side sits a fan of the game 'Guess what I've thought of'. The goal is obvious — to guess. Good luck!"
A TCP service (nc 31.207.77.216 4033) asks you to guess a hidden string. The goal is to recover that string and receive the flag.
Analysis
On connect the service prints:
I have a string, if you guess it, I'll give you the flag.
Try to guess the string:
For each guess the server echoes the input and replies:
You have mistake at position N
where N is the 0-indexed position of the first character that differs between the guess and the secret. Crucially, the comparison only runs up to min(len(guess), len(secret)). If a guess matches the secret on every compared position, no "mistake" line is printed — the server just re-prompts. This silence is a "correct so far" signal, not a win.
This is a textbook position oracle (Mastermind / Wordle-style first-mismatch leak): each request tells you exactly where your candidate string stops matching, so the secret can be peeled off one character at a time.
The secret structure
The secret is:
base64("<random_hex> <client_IP> <YYYY-MM-DD HH:MM:SS>")
Decoded example: 210 147.45.101.108 2026-05-29 09:03:45
- a short random hex token (2–3 hex chars),
- a space,
- the connecting client's public IP,
- a space,
- the date and the time in UTC (
HH:MM:SS).
The plaintext is ~38 bytes, so the base64 is ~51–52 chars.
The trap (the intended lesson)
The naive approach — compute base64(my_IP + " " + now_UTC) locally and submit it — fails. By the time the guess reaches the server, the seconds field has usually drifted by 1, so the guess mismatches at the seconds position, which lands near the end of the base64 string (positions ~46–50). The flag itself spells out the lesson:
REDACTED→ "you've caught the seconds at the end of the string"
The correct approach is recover-then-replay: use the oracle to recover the actual current secret char-by-char (including the live seconds as they are right now), then submit exactly that recovered string in the same session — never recompute the timestamp at submit time.
Solution
Recover the base64 secret one character at a time. To avoid brute-forcing all 64 base64 symbols per position, predict the next character from what we already know:
- Connect, read the banner.
- For the current known base64 prefix, decode it to plaintext.
- If the decoded plaintext has no space yet, we are still inside the random hex token — try hex digits
0-9a-fand a space as the next plaintext char, encode each candidate, and take the base64 char at the current index. - Once we know
<hex>, predict the remainder from the client IP (read directly out of the decoded oracle output — no external lookup needed) plus the current UTC time with a few±secondsoffsets. Encode and take the base64 char at the current index. - Append the full base64 alphabet +
=as a fallback so recovery never stalls.
- If the decoded plaintext has no space yet, we are still inside the random hex token — try hex digits
- For each candidate, send
known + char. If the server's reported mismatch positionMis greater thanlen(known), the char was correct — append it and advance. If a candidate produces neither a mismatch nor an advance,knownis already the full secret — stop. - Submit the fully recovered secret string exactly as recovered and read the output. The server prints
You win!!! Your flag is BugCTF{...}.
Implementation notes:
- B64 alphabet =
A-Z a-z 0-9 + /, plus=for padding fallback. - The
b64dechelper must handlelen % 4 == 1by trimming the last char before padding (an isolated trailing char carries no full byte). - The receive loop reads until
guess the string:orBugCTFappears.
python read-only • copy allowed#!/usr/bin/env python3 import socket, base64, string, time, datetime, re HOST, PORT = "31.207.77.216", 4033 B64 = string.ascii_uppercase + string.ascii_lowercase + string.digits + "+/" def b64dec(s): # tolerate partially-known prefixes if len(s) % 4 == 1: s = s[:-1] s += "=" * ((4 - len(s) % 4) % 4) try: return base64.b64decode(s).decode("latin-1") except Exception: return "" def recv_until(sock, markers=("guess the string:", "BugCTF")): buf = b"" sock.settimeout(5) while True: try: chunk = sock.recv(4096) except socket.timeout: break if not chunk: break buf += chunk text = buf.decode("latin-1", "ignore") if any(m in text for m in markers): break return buf.decode("latin-1", "ignore") def mismatch_pos(text): m = re.search(r"mistake at position (\d+)", text) return int(m.group(1)) if m else None def candidates(known): """Yield likely next base64 chars given the known prefix.""" idx = len(known) pt = b64dec(known) seen = [] def push_pt(plain): enc = base64.b64encode(plain.encode("latin-1")).decode() if idx < len(enc): c = enc[idx] if c not in seen: seen.append(c) if " " not in pt: # still inside the random hex token for h in "0123456789abcdef ": push_pt(pt + h) else: # know "<hex> "; predict IP + UTC time with small drift parts = pt.split(" ") prefix = parts[0] + " " ip = parts[1] if len(parts) > 1 else "" now = datetime.datetime.utcnow() for off in range(-2, 4): t = now + datetime.timedelta(seconds=off) guess_pt = f"{prefix}{ip} {t.strftime('%Y-%m-%d %H:%M:%S')}" push_pt(guess_pt) # fallback: full alphabet so recovery never stalls for c in B64 + "=": if c not in seen: seen.append(c) return seen def main(): s = socket.create_connection((HOST, PORT)) recv_until(s) known = "" while True: advanced = False for ch in candidates(known): guess = known + ch s.sendall(guess.encode() + b"\n") resp = recv_until(s) if "BugCTF" in resp: print(resp) return pos = mismatch_pos(resp) if pos is None: # no mismatch and no flag: prefix already equals the full secret print("[*] recovered full secret:", known) s.sendall(known.encode() + b"\n") print(recv_until(s)) return if pos > len(known): known += ch advanced = True print(f"[+] pos {len(known)-1} -> {ch!r} | {known}") break if not advanced: # could not extend: try submitting what we have print("[*] submitting:", known) s.sendall(known.encode() + b"\n") print(recv_until(s)) return if __name__ == "__main__": main()