314 lines
10 KiB
Python
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()
|