BroncoCTF : Spot the difference Writeup
Challenge
We're given two files, file1.txt and file2.txt, each containing what looks like a long, random blob of letters, digits, and symbols - one character per line. At a glance the two files look identical. The hint: someone "updated" one of the files to smuggle a message inside it.
$ ls file1.txt file2.txt
Initial Recon
Pasting the two files side by side line-by-line makes it clear they're almost the same, but not quite:
paste file1.txt file2.txt | nl
Most lines match. A handful don't. That's the whole challenge, really - find the lines that differ, and figure out which differences actually carry information versus which are just noise.
Step 1 - Diff the files properly
Rather than eyeball a few hundred lines, script it:
f1 = open("file1.txt").read().splitlines()
f2 = open("file2.txt").read().splitlines()
print(len(f1), len(f2))
for i, (a, b) in enumerate(zip(f1, f2), 1):
if a != b:
print(i, a, b)
Gotcha #1: len(f1) and len(f2) come out different - 350 vs 351 lines. The two files aren't the same length, which means a naive zip() silently truncates to the shorter file and - worse - anything after a genuine insertion/deletion point will be misaligned by one position, making every comparison from that point on unreliable.
The fix is to use a real sequence-alignment diff instead of a positional zip(), so insertions and deletions are handled correctly:
import difflib
f1 = "".join(open("file1.txt").read().splitlines())
f2 = "".join(open("file2.txt").read().splitlines())
sm = difflib.SequenceMatcher(None, f1, f2, autojunk=False)
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag != "equal":
print(tag, i1, i2, j1, j2, f1[i1:i2], f2[j1:j2])
This surfaces 79 total differing positions, made up of:
- Plain 1-for-1 character replacements (most of them)
- A couple of true insertions/deletions (e.g. an extra
mappears infile2thatfile1doesn't have;file1has an extraEandXthatfile2doesn't)
Step 2 - Separate signal from noise
Looking at the 79 differences, most of them turn out to be case-only flips of the same letter - e โ E, w โ W, l โ L, and so on. These contribute no new information; they're decoy noise to make the diff look messier than it is.
The interesting subset is the differences that are not explainable as a same-letter case change - a digit turning into a letter, a letter turning into a bracket or underscore, or one letter turning into a genuinely different letter:
interesting = []
for tag, i1, i2, j1, j2 in sm.get_opcodes():
if tag == "equal":
continue
a, b = f1[i1:i2], f2[j1:j2]
if len(a) == len(b):
for k, (x, y) in enumerate(zip(a, b)):
if x.lower() != y.lower():
interesting.append((i1 + k, x, y))
else:
interesting.append((i1, a, b))
Filtering out the case-noise leaves 29 positions where file2 introduces a character that has no case-equivalent relationship to file1's character at that spot. Reading those characters, in position order, off file2:
b r o n c o { y @ y y y _ Y 0 u _ f 0 u n d _ m 3 ! ! }
Step 3 - Read the message
Concatenated: bronco{y@yyy_Y0u_f0und_m3!!}
That's the flag.
The challenge author built two copies of a long random string, then:
- Randomly flipped the case of a bunch of characters throughout - pure visual noise, meant to make a naive diff look overwhelming.
- Substituted in the actual flag characters, one at a time, scattered through the string at positions that are not explainable as case flips.
The trick to solving it is recognizing that "the diff has 79 changes" isn't the same as "there are 79 bits of message" - most of that is chaff, and the job is filtering it out.
Flag
bronco{y@yyy_Y0u_f0und_m3!!}
Takeaways
- When two files "look basically the same," always check their length before diffing character-by-character - a single insertion/deletion will silently desync a positional comparison and make everything after it look wrong.
difflib.SequenceMatcher(ordiff/git diff --word-diff) handles insertions and deletions correctly; a rawzip()loop doesn't.- Not every difference in a diff is meaningful - case-only noise is a common and cheap way for challenge authors to bury a smaller real signal inside a larger, noisier one. Always ask "is this difference explainable by a boring transformation (case, whitespace, encoding)?" before assuming it's part of the message.
Comments
No comments yet. Start the discussion.