DEV Community

Cover image for BroncoCTF : Atomic Substitution Theory Writeup
Yogeshwar Peela
Yogeshwar Peela

Posted on • Originally published at exploitnotes.hashnode.dev

BroncoCTF : Atomic Substitution Theory Writeup

Challenge

We're given secret.txt, a single line of comma-separated tuples wrapped
in what looks like braces and underscores:

(4, 17), (2, 16), (2, 15), (4, 9), { , (3, 2, 1), (5, 3), _ , (2, 17), ...
Enter fullscreen mode Exit fullscreen mode
$ file secret.txt
secret.txt: ASCII text, with very long lines (479), with no line terminators
Enter fullscreen mode Exit fullscreen mode

No further context is given — the challenge is to figure out the encoding
scheme from the data alone.

Recon

The tuples are almost all 2- or 3-element groups of small integers, plus
three literal tokens scattered through the stream: {, }, and _.

Small integer pairs, a {/} wrapper, and an obvious "flag format" hint
(bronco{...}) strongly suggested a coordinate lookup cipher — each
tuple indexes into some 2D reference grid, and the underscore/braces are
literal characters meant to survive the decode untouched.

The first four tuples are the key to spotting the scheme:

(4, 17), (2, 16), (2, 15), (4, 9)
Enter fullscreen mode Exit fullscreen mode

Reading these as (period, group) coordinates on the periodic table:

Tuple Period Group Element Symbol
(4, 17) 4 17 Bromine Br
(2, 16) 2 16 Oxygen O
(2, 15) 2 15 Nitrogen N
(4, 9) 4 9 Cobalt Co

Concatenating the symbols: Br + O + N + Co = BrONCo
"Bronco" — matching the expected flag prefix exactly. Scheme confirmed.

Refining the scheme

Most tuples are 2-element (period, group) pairs and decode to a full
element symbol (1 or 2 letters). But a number of tuples have a third
element, e.g. (4, 17, 2). Decoding these as full symbols produced
garbled output, so the third number was tested as a letter index into
the 2-letter symbol:

(4, 17, 2)  ->  Br, take letter #2  ->  "r"
(2, 1, 2)   ->  Li, take letter #2  ->  "i"
(3, 13, 1)  ->  Al, take letter #1  ->  "A"
Enter fullscreen mode Exit fullscreen mode

This let single letters be pulled out of two-letter element symbols —
necessary because plain English text needs individual letters, not
2-character blocks, at most positions.

The literal tokens map directly:

  • _ → space
  • { / } → literal brace (flag wrapper)

Decoding

Running the full tuple stream through this scheme (see decode.py)
produces:

BrONCo{MY FAVOriTe MeSSAGeS HAVe AT eleMeNT OF S[?9,6?]PriSe}
Enter fullscreen mode Exit fullscreen mode

Nearly the entire message resolves cleanly into a coherent, on-theme
sentence — fitting, since the challenge itself hinges on the "surprise"
of finding a message hidden in periodic table coordinates:

MY FAVORITE MESSAGES HAVE AN ELEMENT OF SURPRISE

Two positions didn't resolve automatically:

  1. (9, 6) — there is no period 9 on the periodic table (periods only go 1–7), so this tuple has no valid lookup. Context makes the intended letter obvious: the word is S_PRISE, which can only sensibly be SURPRISE, so the missing letter is U.
  2. The word decoded as AT reads awkwardly; grammatically the sentence wants AN ("have an element of surprise"). This points to a likely transcription slip in the source tuple for that word (a digit that should differ from what was copied), rather than an error in the decoding scheme itself — every other tuple in the file resolves without issue.

Both anomalies are localized to specific known tuples and don't affect
confidence in the rest of the decode, which is fully self-consistent
across ~50 other tuples.

Final Flag

bronco{MY_FAVORITE_MESSAGES_HAVE_AN_ELEMENT_OF_SURPRISE}
Enter fullscreen mode Exit fullscreen mode

(Spaces converted to underscores to match standard flag formatting
conventions.)

Tools

decode.py — standalone Python decoder implementing the scheme above.

Usage:

python3 decode.py secret.txt
Enter fullscreen mode Exit fullscreen mode

Full source:

#!/usr/bin/env python3
"""
Periodic Table Cipher Decoder
==============================

Encoding scheme discovered from secret.txt:

  - Each token is either:
      * A literal character: { } _
      * A tuple (period, group)        -> full element symbol (1 or 2 letters)
      * A tuple (period, group, index) -> a single letter from that symbol,
                                           where index is 1-based (1st or 2nd letter)

  - "_" represents a space / underscore separator in the final message
  - "{" and "}" are literal brace characters (used for the flag wrapper)

Example:
    (4,17) (2,16) (2,15) (4,9)  ->  Br O N Co  ->  "BrONCo" -> "Bronco"

Usage:
    python3 decode.py secret.txt
"""

import re
import sys

# Standard periodic table symbols by (period, group), IUPAC 1-18 group numbering.
# Only positions that are actually populated on a real periodic table are included.
PERIODIC_TABLE = {
    (1, 1): 'H',  (1, 18): 'He',

    (2, 1): 'Li', (2, 2): 'Be', (2, 13): 'B',  (2, 14): 'C',  (2, 15): 'N',
    (2, 16): 'O', (2, 17): 'F', (2, 18): 'Ne',

    (3, 1): 'Na', (3, 2): 'Mg', (3, 13): 'Al', (3, 14): 'Si', (3, 15): 'P',
    (3, 16): 'S', (3, 17): 'Cl', (3, 18): 'Ar',

    (4, 1): 'K',  (4, 2): 'Ca', (4, 3): 'Sc', (4, 4): 'Ti', (4, 5): 'V',
    (4, 6): 'Cr', (4, 7): 'Mn', (4, 8): 'Fe', (4, 9): 'Co', (4, 10): 'Ni',
    (4, 11): 'Cu', (4, 12): 'Zn', (4, 13): 'Ga', (4, 14): 'Ge', (4, 15): 'As',
    (4, 16): 'Se', (4, 17): 'Br', (4, 18): 'Kr',

    (5, 1): 'Rb', (5, 2): 'Sr', (5, 3): 'Y',  (5, 4): 'Zr', (5, 5): 'Nb',
    (5, 6): 'Mo', (5, 7): 'Tc', (5, 8): 'Ru', (5, 9): 'Rh', (5, 10): 'Pd',
    (5, 11): 'Ag', (5, 12): 'Cd', (5, 13): 'In', (5, 14): 'Sn', (5, 15): 'Sb',
    (5, 16): 'Te', (5, 17): 'I', (5, 18): 'Xe',

    (6, 1): 'Cs', (6, 2): 'Ba', (6, 3): 'La', (6, 4): 'Hf', (6, 5): 'Ta',
    (6, 6): 'W',  (6, 7): 'Re', (6, 8): 'Os', (6, 9): 'Ir', (6, 10): 'Pt',
    (6, 11): 'Au', (6, 12): 'Hg', (6, 13): 'Tl', (6, 14): 'Pb', (6, 15): 'Bi',
    (6, 16): 'Po', (6, 17): 'At', (6, 18): 'Rn',

    (7, 1): 'Fr', (7, 2): 'Ra', (7, 3): 'Ac', (7, 4): 'Rf', (7, 5): 'Db',
    (7, 6): 'Sg', (7, 7): 'Bh', (7, 8): 'Hs', (7, 9): 'Mt', (7, 10): 'Ds',
    (7, 11): 'Rg', (7, 12): 'Cn', (7, 13): 'Nh', (7, 14): 'Fl', (7, 15): 'Mc',
    (7, 16): 'Lv', (7, 17): 'Ts', (7, 18): 'Og',
}

# Matches: {, }, _, or a tuple like (4, 17) / (4, 17, 2)
TOKEN_RE = re.compile(r'\{|\}|_|\(\s*\d+\s*,\s*\d+\s*(?:,\s*\d+\s*)?\)')
TUPLE_RE = re.compile(r'\(\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*(\d+)\s*)?\)')


def decode(text: str) -> str:
    output = []
    for match in TOKEN_RE.finditer(text):
        token = match.group(0)

        if token in ('{', '}'):
            output.append(token)
            continue
        if token == '_':
            output.append(' ')
            continue

        tm = TUPLE_RE.match(token)
        period, group, index = tm.groups()
        period, group = int(period), int(group)
        symbol = PERIODIC_TABLE.get((period, group))

        if symbol is None:
            output.append(f'[?{period},{group}?]')
            continue

        if index is None:
            output.append(symbol)             # full symbol
        else:
            idx = int(index)
            if 1 <= idx <= len(symbol):
                output.append(symbol[idx - 1])  # single letter
            else:
                output.append(f'[?{symbol}#{idx}?]')

    return ''.join(output)


def main():
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <secret.txt>")
        sys.exit(1)

    with open(sys.argv[1], 'r') as f:
        raw = f.read()

    result = decode(raw)
    print("Decoded message:")
    print(result)


if __name__ == '__main__':
    main()
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  • When a cipher's tuples don't line up with an obvious 1:1 substitution, try mapping them onto a real-world reference table (periodic table, keyboard layout, ASCII table, book/page/line, etc.) rather than assuming a purely mathematical transform.
  • Confirming a scheme against a small, checkable prefix (here, the bronco{ flag wrapper) before decoding the whole message saves a lot of wasted effort chasing the wrong theory.
  • When a decode is 95% clean and self-consistent, isolated garbage characters are usually a transcription/OCR artifact in the source data rather than a flaw in the scheme — worth verifying against the raw file before assuming the cipher logic itself is wrong.

Top comments (0)