DEV Community

Cover image for BroncoCTF : Spot the difference Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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])
Enter fullscreen mode Exit fullscreen mode

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 m appears in file2 that file1 doesn't have; file1 has an extra E and X that file2 doesn'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 — eE, wW, lL, 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))
Enter fullscreen mode Exit fullscreen mode

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  !  !  }
Enter fullscreen mode Exit fullscreen mode

Step 3 — Read the message

Concatenated:

bronco{y@yyy_Y0u_f0und_m3!!}
Enter fullscreen mode Exit fullscreen mode

That's the flag. The challenge author built two copies of a long random
string, then:

  1. Randomly flipped the case of a bunch of characters throughout — pure visual noise, meant to make a naive diff look overwhelming.
  2. 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!!}
Enter fullscreen mode Exit fullscreen mode

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 (or diff/git diff --word-diff) handles insertions and deletions correctly; a raw zip() 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.

Top comments (0)