Files
ctf/2025/lake/misc/wordler/gemini_alone.py
2025-11-28 23:48:04 +01:00

314 lines
10 KiB
Python

from pwn import *
import z3
import re
import os
from collections import defaultdict
# --- Configuration ---
# Update these if the challenge parameters change
HOST = "chall.polygl0ts.ch"
PORT = 6052
WORDLIST_PATH = "word_list.txt"
context.log_level = 'info'
def get_word_lengths(raw_bytes):
"""Parses the initial 'Structure: ...' line to get word lengths."""
try:
decoded = raw_bytes.decode('utf-8', errors='ignore')
except:
return []
# Look for the visual structure line
for line in decoded.splitlines():
if "Structure:" in line:
# Example: Structure: ■■■■■_■■■■
pattern_str = line.split("Structure:")[1].strip()
word_segments = pattern_str.split('_')
# Length is number of block characters (or just length of segment if stripped)
# UTF-8 block is 3 bytes, but decoded string is 1 char.
return [len(segment) for segment in word_segments]
return []
def load_wordlist(path):
"""Loads wordlist into a dictionary keyed by length."""
if not os.path.exists(path):
log.error(f"Wordlist {path} not found!")
exit(1)
words_by_len = defaultdict(list)
with open(path, 'r', encoding='utf-8', errors='ignore') as f:
for line in f:
w = line.strip().lower()
if w:
words_by_len[len(w)].append(w)
return words_by_len
def parse_ansi_feedback(raw_bytes):
"""Extracts (color, char) tuples from ANSI output."""
try:
decoded = raw_bytes.decode('utf-8', errors='ignore')
except:
return []
# Heuristic: Find the line with ANSI codes and underscores
lines = decoded.splitlines()
target_line = ""
for line in lines:
if "\x1b[" in line and "_" in line:
target_line = line.strip()
break
# If heuristic fails, scan whole block
if not target_line:
target_line = decoded
raw_segments = target_line.split('_')
parsed_segments = []
# Matches ANSI code and the letter immediately after
ansi_pattern = re.compile(r'\x1b\[(\d+)m([A-Z])')
for raw_seg in raw_segments:
matches = ansi_pattern.findall(raw_seg)
if matches:
parsed_segments.append(matches)
return parsed_segments
def analyze_global_constraints(all_segments):
"""
Returns constraints for *this specific guess result*.
Format: {char: {'min': k, 'max': k or None}}
"""
all_entries = [item for seg in all_segments for item in seg]
char_data = defaultdict(lambda: {'92': 0, '93': 0, '90': 0})
for color, char in all_entries:
char_data[char.lower()][color] += 1
constraints = {}
for char, counts in char_data.items():
greens = counts['92']
yellows = counts['93']
greys = counts['90']
required = greens + yellows
if greys > 0:
# If we see a grey, the count is exact
constraints[char] = {'min': required, 'max': required}
else:
# If no grey, we only know the minimum
constraints[char] = {'min': required, 'max': None}
return constraints
def filter_candidates(current_lists, segments, global_cons):
"""
Filters the *existing* candidate lists based on positional feedback (Green/Yellow/Grey).
This reduces the search space for Z3 significantly.
"""
new_lists = []
# Identify chars that are globally forbidden (max count is 0)
forbidden_chars = {c for c, lim in global_cons.items() if lim['max'] == 0}
for i, seg in enumerate(segments):
# 1. Build Local Constraints for this slot
local_fixed = {} # Index -> Char (Green)
local_not_at = [] # (Index, Char) (Yellow or Grey)
for idx, (color, char) in enumerate(seg):
c = char.lower()
if color == '92': # Green
local_fixed[idx] = c
elif color == '93': # Yellow
local_not_at.append((idx, c))
elif color == '90': # Grey
# Grey in Wordle means "not at this position" locally
local_not_at.append((idx, c))
# 2. Apply to current candidates
filtered = []
for w in current_lists[i]:
# A. Global Forbidden Check
if any(c in forbidden_chars for c in w): continue
# B. Green Check (Must match exact position)
if any(w[pos] != char for pos, char in local_fixed.items()): continue
# C. Yellow/Grey Check (Must NOT be at this position)
if any(w[pos] == char for pos, char in local_not_at): continue
filtered.append(w)
new_lists.append(filtered)
log.info(f"Slot {i+1}: Candidates reduced from {len(current_lists[i])} to {len(filtered)}")
return new_lists
def solve_with_z3(candidate_lists, global_constraints):
"""
Uses Z3 to pick one word from each candidate list such that their combined
letter counts satisfy the global_constraints.
"""
s = z3.Solver()
selected_flags = []
# 1. Create variables: One boolean per candidate word
for slot_idx, words in enumerate(candidate_lists):
slot_flags = []
for word_idx, w in enumerate(words):
v = z3.Bool(f"s{slot_idx}_{word_idx}")
slot_flags.append(v)
selected_flags.append(slot_flags)
# Constraint: Select exactly one word per slot
s.add(z3.PbEq([(f, 1) for f in slot_flags], 1))
# 2. Add Global Count Constraints
# We sum the character counts of selected words
for char, limits in global_constraints.items():
terms = []
for slot_idx, words in enumerate(candidate_lists):
# Pre-calculate counts for efficiency
# If a word has 2 'e's, we add (2 * bool_var) to the sum
for w_idx, w in enumerate(words):
c_count = w.count(char)
if c_count > 0:
terms.append(z3.If(selected_flags[slot_idx][w_idx], c_count, 0))
if not terms:
# Edge case: If min > 0 but no candidates have the char -> UNSAT
if limits['min'] > 0:
s.add(False)
continue
total = z3.Sum(terms)
s.add(total >= limits['min'])
if limits['max'] is not None:
s.add(total <= limits['max'])
log.info("Z3: Solving for valid combination...")
if s.check() == z3.sat:
m = s.model()
result_words = []
for slot_idx, flags in enumerate(selected_flags):
for w_idx, f in enumerate(flags):
if z3.is_true(m[f]):
result_words.append(candidate_lists[slot_idx][w_idx])
break
return "_".join(result_words)
else:
return None
def main():
# 1. Connect
r = remote(HOST, PORT)
# 2. Read Initial State
log.info("Reading initial structure...")
try:
initial_output = r.recvuntil(b"Your guess: ", timeout=5)
except:
initial_output = r.recv()
log.info(f"Initial Output: {initial_output.decode(errors='ignore')}")
# 3. Parse Lengths & Setup
lengths = get_word_lengths(initial_output)
if not lengths:
log.error("Could not parse word lengths! Check connection or format.")
return
log.success(f"Target Lengths: {lengths}")
word_dict = load_wordlist(WORDLIST_PATH)
# Initialize candidates for each slot based on length
candidates = []
for l in lengths:
if l in word_dict:
candidates.append(word_dict[l])
else:
log.error(f"No words of length {l} in wordlist!")
return
# Cumulative Global Constraints (start empty)
# format: {char: {'min': 0, 'max': None}}
cum_global_cons = defaultdict(lambda: {'min': 0, 'max': None})
# 4. Game Loop
# We loop until solved.
# First guess: Just pick the first word from each list (random start)
current_guess = "_".join([c[0] for c in candidates])
step = 0
while True:
step += 1
log.info(f"--- Round {step} ---")
log.info(f"Sending guess: {current_guess}")
r.sendline(current_guess.encode())
# Read response
try:
response = r.recvuntil(b"Your guess: ", timeout=2)
except:
# Connection might close on win, or lag
response = r.recvall(timeout=1)
if b"Correct" in response or b"Flag" in response or b"flag" in response:
log.success(f"WINNER! \n{response.decode(errors='ignore')}")
break
else:
log.info("Stream ended or timed out.")
log.info(response.decode(errors='ignore'))
break
# Check for Win text
if b"Correct!" in response:
log.success("Correct solution found!")
log.info(response.decode(errors='ignore'))
break
# 5. Process Feedback
segments = parse_ansi_feedback(response)
if not segments:
log.warning("Could not parse ANSI feedback. Retrying read...")
continue
# 6. Update Constraints & Candidates
# Update Globals (Accumulate)
new_globals = analyze_global_constraints(segments)
for char, limits in new_globals.items():
# Min can only go up (if we need at least 1, then at least 2...)
cum_global_cons[char]['min'] = max(cum_global_cons[char]['min'], limits['min'])
# Max can only go down (if max 3, then max 2...)
if limits['max'] is not None:
current_max = cum_global_cons[char]['max']
if current_max is None:
cum_global_cons[char]['max'] = limits['max']
else:
cum_global_cons[char]['max'] = min(current_max, limits['max'])
# Filter Candidates (Locals)
candidates = filter_candidates(candidates, segments, cum_global_cons)
if any(len(c) == 0 for c in candidates):
log.error("Empty candidate list! The correct word is missing from word_list.txt.")
break
# 7. Generate Next Guess using Z3
guess_str = solve_with_z3(candidates, cum_global_cons)
if not guess_str:
log.error("Z3 says UNSAT. Contradictory constraints or missing words.")
break
current_guess = guess_str
if __name__ == "__main__":
main()