Introduction

    A few days ago, I participated in UIUCTF 2026, a Jeopardy-style CTF competition, once again playing together with my team Echelon Obscura. As usual, I spent a considerable amount of time looking through the reversing and cryptography challenges and one of the challenges that caught my attention was Rune Decryptor.

    At first glance, the challenge seemed to be a relatively straightforward substitution cipher. However, after connecting to the remote service, it quickly became apparent that the substitution itself was only one part of the problem. The plaintext could be written in 10 different languages, a new cipher had to be solved repeatedly and the entire process had to be completed interactively before losing the connection to the server.

    This made Rune Decryptor particularly interesting to me because the main difficulty was not discovering some obscure cryptographic vulnerability. Instead, the challenge was about taking a classical cryptanalysis problem and making the solving process sufficiently reliable and automated to handle many different languages.

    In this writeup, I will go through the approach I used to automate the cryptanalysis, how I adapted an old multilingual substitution-cipher project of mine for the challenge and how a combination of statistical analysis and a small amount of manual correction eventually allowed me to solve all 20 rounds.

Challenge Overview

    The challenge description was very short:

I came across an oracle speaking in strange runes - can you help me decipher them?

Please do not hammer the server.

Challenge Information

    Unlike many cryptography challenges, no files were provided. The only thing we were given was a remote endpoint:

ncat --ssl rune-decryptor.chal.uiuc.tf 1337

    Connecting to it did not immediately present the challenge. Instead, the server first required solving a proof-of-work:

$ ncat --ssl rune-decryptor.chal.uiuc.tf 1337
== proof-of-work: enabled ==
please solve a pow first
You can run the solver with:
    python3 <(curl -sSL https://goo.gle/kctf-pow) solve s.ABod.AADgwBISvgDzwY2xmwYpHo7Z
===================

Solution? s.AABWfcb5IicO5WPGgzDP4l9l7VWnOnlkoX1/DLbwl9XHhy71xuTjnaVlCv1hz1vR99OQXS5q/hs8FZRJkOp7e2zZnrb/4mu2S5oO50rIbROzLInnYD3IIE1xzqbiPM3tzo7Q1py1hrJxCUzF61LyDe0C5WF14qG05eAoA3EYYrHMkLkchxp9wikb58SnXc0IxDzowlKhPNWSVZC1fWP2Ws9Z
Correct

    After following the provided instructions and submitting the resulting proof-of-work, the actual challenge appeared:

========================================================================
                         🞄🞄🞄 RUNE DECRYPTOR 🞄🞄🞄
========================================================================

 - original paragraph is of unknown language
 - language from one of
  - de=German
  - en=English
  - es=Spanish
  - fr=French
  - grc=Ancient Greek
  - it=Italian
  - la=Latin
  - nl=Dutch
  - ru=Russian
  - sv=Swedish
 - text is mapped via monoalphabetic substitution
 - submit decrypted text (5 attempts per round)
 - if you get text wrong, we report back # of correct symbols
 - >70% of 20 rounds = flag

========================================================================

    So, the rules were relatively simple. The server selected a paragraph in one of ten possible languages and replaced every character using a monoalphabetic substitution, with the resulting alphabet represented using rune-like Unicode characters. Later, while looking more closely at the symbols used by the challenge, I realized that they were actually Elder Futhark runes, the oldest form of the runic alphabets. We had five attempts to recover the exact plaintext for each round and solving more than 70% of the 20 rounds would reveal the flag.

    The first round looked like this:

------------------------------------------------------------------------
Round 1/20      solved so far: 0
------------------------------------------------------------------------
ᚾᚢ ᛃᛡᛦᛞ ᚢᚲ ᛘᚺᚾᚫ. ᛡᛇ ᛞᛦ ᛊᛡᚱᛡᚾᛇ ᛇᚺᛣᚨᚲ ᛡᛦ ᚸᛡᚨᛣᚲ. ᚾᚢ ᛢ ᛡ ᚢᛡ ᚸᛡᛇ ᛁᚲᛇ ᛃᚲᚱᚱᚲᛇ ᚢᚲᛇᚷᛦᚲᚢᚢᚲᛇ ᛃᛦᚫᚲᛣᛞ ᚢᚲᛇ ᛏᚢᛦᛇ ᚸᚲᚢᚢᚲᛇ ᚲᛞ ᚢᚲᛇ ᚱᚾᚲᛦᚳ ᛡᚾᚱᚲᚲᛇ. ᚲᚢᚢᚲᛇ ᚺᛣᛞ ᚢᚲᛇ ᚦᛝᚲᛘᚲᛦᚳ ᛞᚾᚫᚲᛇ ᛡ ᛃᛡᚾᚫᚲ ᛏᚲᛦᚫ. ᚢᚲᛦᚫ ᚦᚺᚲᛦᚫ ᛁᛡᛣᛇ ᚦᚲᛞᛞᚲ ᛣᛦᚾᛞ ᚲᛇᛞ ᚢᛡ ᛏᚫᚺᚾᚲ ᛁᛦ ᛏᚫᚲᚱᚾᚲᚫ ᛘᚲᛣᛦ. ᚷᛦᚲ ᛏᚲᛣᛇᚲᛣᛞ ᚲᚢᚢᚲᛇ. ᚲᚢᚢᚲᛇ ᚲᛣᛘᚾᚲᛣᛞ ᚢᚲᛇ ᚲᛇᚦᚢᛡᛘᚲᛇ ᚷᛦᚾ ᚫᚲᛏᚫᚾᛇᚲᛣᛞ ᚢᛡ ᛘᚾᚲ ᛡ ᚨᚫᛡᛣᛁ ᛏᚲᚾᛣᚲ ᚦᚺᚱᚱᚲ ᛁᚲᛇ ᚸᛡᛇ. ᚲᚢᚢᚲᛇ ᚫᚲᛘᚲᛣᛞ ᛡ ᚢᛡ ᛏᚫᚾᛇᚺᛣ ᛁᚲᛇ ᚸᚫᛡᛇ ᛁ ᛝᚺᚱᚱᚲᛇ ᛏᚢᛦᛇ ᛁᚺᛦᚦᚲ ᚷᛦᚲ ᚦᚲᚢᚢᚲ ᚷᛦᚾ ᛁᛡᛣᛇᚲ ᚲᛣ ᚸᛡᛇ ᛁᛦ ᚨᚢᚺᚸᚲ ᛇᛦᚫ ᚢᚲᛇ ᚲᚳᛞᚫᚲᚱᚲᛇ ᛃᚢᚺᛞᛇ ᛁᛦ ᛏᛡᚦᚾᛃᚾᚷᛦᚲ. ᚲᚢᚢᚲᛇ ᛏᚲᛣᛇᚲᛣᛞ ᛡ ᛣᚲ ᛏᛡᛇ ᛏᚲᛣᛇᚲᚫ.

                            ██████; ██, ██ ████████ -- ██████, ████-████

[5 attempt(s) left] >

    The line of blocks at the bottom was also useful. After successfully decrypting a paragraph, the service revealed that it corresponded to the title and author of the original work. However, what we actually had to submit was the complete plaintext paragraph.

Automating the Cryptanalysis

    The part that initially surprised me the most was the number of languages involved. I am reasonably familiar with English, German, Ancient Greek and Dutch, so manually inspecting candidate plaintexts in those languages was at least feasible. For Spanish, French, Italian, Latin, Russian and Swedish, however, I was not familiar with the languages, so I could not reliably tell whether the output was valid plaintext or simply a very convincing, but still incorrect, substitution.

    More importantly, even for the languages I knew, manually solving 20 independent monoalphabetic substitutions was not particularly realistic. Each connection had to remain alive while I worked through all of the rounds, so spending a large amount of time manually performing frequency analysis and guessing mappings for every paragraph was not a good strategy.

    That was when I remembered an older project I had written for analyzing text and attacking substitution ciphers. Conveniently, the project was already polyglot, although at the time I had only implemented support for English, Greek and German.

    Instead of writing a solver from scratch, I decided to bring that project back to life and extend it for the challenge. I named the resulting program runebreaker and it can be found here.

    The basic architecture of the solver is relatively simple. For each supported language, the program builds a statistical model from a corpus of text. The model combines character-level statistics, particularly trigram and quadgram frequencies, with information about the frequency of complete words.

    Given a rune ciphertext, the solver first constructs a candidate substitution alphabet and repeatedly modifies it. Each candidate key is used to decrypt the paragraph, after which the resulting plaintext is scored against the statistical model of the selected language. Better-scoring substitutions are retained while worse ones may occasionally be accepted during the search, allowing the solver to escape local optima rather than becoming stuck on the first plausible mapping it encounters.

    The same procedure can then be executed against every supported language. Because meaningful plaintext produces substantially better n-gram and word-frequency scores than random substitutions, the resulting candidates can be ranked and the most likely language usually becomes immediately obvious.

    For example, one Latin round produced results similar to the following:

$ python runebreaker.py solve cipher.txt --corpora corpora --restarts 1 --jobs 8

========================================================================
de: German
========================================================================
[de]   1/1 best=-16.09658  asozue us oh dintuesuhaner obladimolur lewelsol eautriha tuldumit zua nacam ossumelans tfohinet offe

========================================================================
en: English
========================================================================
[en]   1/1 best=-15.60842  itawhe ht am conshethmined auricolarhd reyertar eihsdomi shrchlos whi nivil atthlerint spamones appe

========================================================================
es: Spanish
========================================================================
[es]   1/1 best=-15.42197  alique ul im porsuelumaren iycapodicun ceheclic eausnoma sucpudos qua rabad illudecarl stimores itte

========================================================================
fr: French
========================================================================
[fr]   1/1 best=-14.96977  etiqua ut id consuatudenal ibrecomirul ragartir aeuslode surcumos que nehem ittumarent spidonas ippa

========================================================================
grc: Ancient Greek
========================================================================
[grc]   1/1 best=-15.55108  ειαδηο ηι αθ ρωσνηοιηθεσου αλτερωματηυ τοβοτιατ οεηνυωθε νητρημων δηε σεχεμ αιιημοτεσι νκαθωσον ακκο

========================================================================
it: Italian
========================================================================
[it]   1/1 best=-14.85483  itaque ut ad consuetudinel agricomarul revertar eiuslodi surcumos qui nizim attumerint spadones appe

========================================================================
la: Latin
========================================================================
[la]   1/1 best=-12.41460  itaque ut ad consuetudinem agricolarum revertar eiusmodi surculos qui nihil attulerint spadones appe

    The Latin candidate is clearly separated from the remaining results, with a score of approximately -12.41, while even the closely related Italian model only reaches approximately -14.85.

    Once the likely language was identified, I could rerun the solver only against that model with more iterations:

$ python runebreaker.py solve cipher.txt \
    --language la \
    --corpora corpora \
    --restarts 2 \
    --steps 40000 \
    --jobs 8

This produced a much cleaner final candidate:

========================================================================
la: Latin
========================================================================
[la]   1/2 best=-12.41460  itaque ut ad consuetudinem agricolarum revertar eiusmodi surculos qui nihil attulerint spadones appe
[la]   2/2 best=-12.41460  itaque ut ad consuetudinem agricolarum revertar eiusmodi surculos qui nihil attulerint spadones appe


RANKED RESULTS
########################################################################

la (Latin) score=-12.414605
------------------------------------------------------------------------
itaque ut ad consuetudinem agricolarum revertar eiusmodi surculos qui nihil attulerint spadones appellant. quod non facerent nisi suspicarentur inhabiles frugibus. quae et ipsa appellatio rationem mihi subiecit non eligendi malleolos quamvis probabili parte vitis enatos si fructum non tulissent quamquam et hos ipsos sciam non in totum sterilitate affectos. nam confiteor pampinarios quoque cum e duro processerint tempore anni sequentis adquirere fecunditatem et ideo in resecem summitti


MAPPING
  ᚲ -> a
  ᛡ -> b
  ᛟ -> c
  ᛃ -> d
  ᛗ -> e
  ᚷ -> f
  ᚹ -> g
  ᛦ -> h
  ᛝ -> i
  ᚢ -> l
  ᚨ -> m
  ᛁ -> n
  ᛒ -> o
  ᛏ -> p
  ᛠ -> q
  ᛚ -> r
  ᚫ -> s
  ᛣ -> t
  ᚦ -> u
  ᚺ -> v

    The solver also printed the recovered rune-to-character mapping, which became particularly useful when fixing the occasional incorrect character.

    After extending the original project with corpora and normalization rules for all ten languages used by the challenge, I finally had something capable of turning most rounds from a wall of runes into an almost completely readable paragraph within a reasonable amount of time.

Dealing With Incorrect Characters

    Although the solver turned out to be surprisingly accurate, almost correct was not always good enough. The service expected the exact plaintext and the statistical search occasionally mapped one or two characters incorrectly. This happened especially with characters that occurred very rarely in the paragraph. That makes sense: if a symbol appears only once or twice, the statistical model has considerably less evidence for deciding where it belongs in the substitution alphabet.

    Fortunately, the challenge provided a very useful oracle whenever an answer was wrong:

Incorrect. 21/22 symbols mapped correctly.

    This did not tell us which character was incorrect, but it confirmed that the candidate was extremely close. For languages I knew, correcting these mistakes was generally easy. Reading the paragraph was enough to spot words containing an obviously incorrect character and infer the intended substitution.

    The more interesting cases were the languages I did not know. There, looking at a sentence that was 95% correct did not necessarily tell me whether a strange-looking word was genuinely valid or contained the incorrect mapping. For those rounds, I used ChatGPT to help identify the remaining incorrect characters. I provided the candidate plaintext together with the information returned by the server about how many rune mappings were correct. Since the substitution was already almost completely recovered, this reduced the task to finding one or two suspicious letters rather than asking it to solve the cryptogram itself.

    A good example occurred with Spanish, where the solver recovered:

entonces el zudio le dizo. oh mi senor. hace tiempo que pensaba ir en tu busca para hablarte de un asunto y ahora me favorece la casualidad puesto que te encuentro. sabe pues. oh mi zoven senor. que tu padre el visir con quien estaba yo en relaciones mercantiles habia fletado naves que ahora vuelven cargadas de mercancias. estas naves vienen consignadas a el. si quisieras cederme su carga te ofreceria mil dinares por cada una y te pagaria al contado.
Incorrect. 21/22 symbols mapped correctly.
[4 attempt(s) left] >

    The statistical solution was extremely close, but rare characters had been confused. Correcting those mappings changed words such as zudio, dizo and zoven into the expected Spanish forms:

entonces el judio le dijo. oh mi senor. hace tiempo que pensaba ir en tu busca para hablarte de un asunto y ahora me favorece la casualidad puesto que te encuentro. sabe pues. oh mi joven senor. que tu padre el visir con quien estaba yo en relaciones mercantiles habia fletado naves que ahora vuelven cargadas de mercancias. estas naves vienen consignadas a el. si quisieras cederme su carga te ofreceria mil dinares por cada una y te pagaria al contado.
CORRECT.   [Spanish]
               El libro de las mil noches y una noche; t. 2 -- Anonymous

    This pattern repeated several times throughout the challenge. The program did the computationally expensive part of recovering essentially the entire substitution alphabet, while the server feedback and occasional manual language correction took care of the last low-frequency characters.

Solving the 20 Rounds

    With this workflow in place, the challenge became much more manageable. For every round, I copied the rune ciphertext into a file named cipher.txt and initially ran the solver against all ten language models with a relatively small number of restarts. The resulting scores usually made the correct language obvious. I then reran the solver specifically for that language with more iterations, inspected the recovered plaintext and submitted it to the server.

    If it was accepted, I simply moved to the next round. If the server reported that one or two symbols were wrong, I used the recovered substitution table together with the plaintext to determine which low-frequency mappings needed to be exchanged.

    For example, one Italian round initially produced:

avrei preferito cze mi avesse dato un posto alla sua scodella se ne avesse una di ben provveduta. ma ecco la ragione del nostro disgusto quella cze sembrami probabile almeno peroccze non so proprio bene percze egli mi glorificzi sempre del nome d infame ogni qual volta mi trova sulla sua strada. infame. cosa diavolo vuol dire. credo cze derivi dal latino in fame o da qualcosa cze significa aver sempre fame. ebbene a mia fe bimbo mio tu zai doppiamente ragione zo sempre fame io.

    The server responded:

Incorrect. 19/20 symbols mapped correctly.
[4 attempt(s) left] >

    Only a single mapping was wrong. Correcting the mapping responsible for z immediately transformed several words at once:

avrei preferito che mi avesse dato un posto alla sua scodella se ne avesse una di ben provveduta. ma ecco la ragione del nostro disgusto quella che sembrami probabile almeno perocche non so proprio bene perche egli mi glorifichi sempre del nome d infame ogni qual volta mi trova sulla sua strada. infame. cosa diavolo vuol dire. credo che derivi dal latino in fame o da qualcosa che significa aver sempre fame. ebbene a mia fe bimbo mio tu hai doppiamente ragione ho sempre fame io.

    and the server accepted it:

CORRECT.   [Italian]
        Memorie di Giuda, vol. I -- Petruccelli della Gattina, Ferdinan…

    The same feedback mechanism was extremely helpful in Russian and Swedish rounds as well, where an otherwise excellent statistical solution could still contain a handful of incorrect low-frequency letters.

    Eventually, after repeating the process for every paragraph, I reached the final round. The last plaintext was English:

------------------------------------------------------------------------
Round 20/20     solved so far: 19
------------------------------------------------------------------------
ᛇᚨᛟ. ᛟᛜᛊᚺᚨ ᚾᛊᛘᛉ ᛉᛦᛊ ᚨᚢᚢᛇ ᚴᛈᛉ ᚺᛚ ᛡ ᛘᛊᚱ ᛇᚺᛚᛈᛉᛊᛟ ᚨᛊᛉᛈᚨᛚᛊᚸ ᚴᚨᚺᛚᛖᚺᛚᛖ ᚱᚺᛉᛦ ᛦᛊᚨ ᛡ ᚾᚺᛉᛉᚾᛊ ᛖᛡᚨᛇᛊᛚᛉ ᛡᛖᛊ ᛟᛉᛡᚺᛚᛊᚸ ᚴᛈᛉ ᚢᛉᛦᛊᚨᚱᚺᛟᛊ ᛜᛊᚨᛘᛊᚦᛉ. ᛟᛦᛊ ᛦᛊᚾᚸ ᛉᛦᛊ ᛖᛡᚨᛇᛊᛚᛉ ᛈᛜ ᛉᚢ ᛉᛦᛊ ᚾᚺᛖᛦᛉ ᛡᛚᚸ ᛜᚢᚺᛚᛉᛊᚸ ᛉᚢ ᛡ ᚾᛊᛉᛉᛊᚨ ᛇᛡᚨᛝ. ᛉᛦᛊ ᛇᛡᚨᛝᛟ ᚱᛊᚨᛊ ᛘᚺᛚᛊ ᛠᛊᚨᛣ ᛘᚺᛚᛊ ᚴᛈᛉ ᛉᛦᛊ ᚸᛊᛉᛊᚦᛉᚺᛠᛊ ᛦᛡᚸ ᛦᚺᛟ ᛖᚾᛡᛟᛟ ᚱᚺᛉᛦ ᛦᚺᛇ. ᛦᛊ ᛟᛈᚴᛞᛊᚦᛉᛊᚸ ᛉᛦᛊ ᚾᛊᛉᛉᛊᚨᛟ ᛉᚢ ᚺᛚᛟᛜᛊᚦᛉᚺᚢᛚ ᛡᛚᚸ ᛜᚾᛡᚺᛚᚾᛣ ᛇᛡᚸᛊ ᚢᛈᛉ ᛉᛦᛊ ᛉᚱᚢ ᚾᛊᛉᛉᛊᚨᛟ ᛡ. ᛟ. ᛡᛚᚸ ᛉᛦᛊᚨᛊ ᛟᛦᚢᛉ ᛡ ᛉᛦᚨᚺᚾᚾ ᛉᛦᚨᚢᛈᛖᛦ ᛦᚺᛟ ᛘᚨᛡᛇᛊ ᚱᛦᚺᚾᛊ ᛉᛦᛊ ᚱᚢᛇᛡᛚ ᚱᛡᛉᚦᛦᛊᚸ ᛦᚺᛇ ᚱᚺᛉᛦ ᛊᛡᛖᛊᚨ ᛊᛣᛊᛟ ᛡᛚᚸ ᛟᛦᛊ ᛟᛡᚺᚸ

         █ ██████████ ██████; ██, █ █████████'█ ██████████ █████ -- ███…

[5 attempt(s) left] >
mrs. speir left the room but in a few minutes returned bringing with her a little garment age stained but otherwise perfect. she held the garment up to the light and pointed to a letter mark. the marks were fine very fine but the detective had his glass with him. he subjected the letters to inspection and plainly made out the two letters a. s. and there shot a thrill through his frame while the woman watched him with eager eyes and she said

The server accepted it and, after all 20 rounds, finally printed:

CORRECT.   [English]
         A Successful Shadow; Or, A Detective's Successful Quest -- Old…

========================================================================
Solved 20/20 (100%).
uiuctf{Po1ygl0t_Pr4ctIC3}
========================================================================

    So, despite the challenge only requiring more than 70% of the rounds, the final result was 20/20 solved.

Conclusion

    Rune Decryptor was a very different kind of cryptography challenge from the ones I usually encounter. There was no implementation vulnerability to exploit and no complicated piece of mathematics hidden behind the cipher. The underlying cryptographic primitive was one of the oldest possible ones: a monoalphabetic substitution cipher.

    What made the challenge interesting was its scale and its multilingual nature. Solving a single substitution cipher manually would have been straightforward. Solving 20 of them, drawn from ten different languages and under the constraints of an interactive remote connection, turned the problem into an automation challenge.

    For me, the most interesting part was being able to reuse an old project that I had almost forgotten about. What originally supported only English, German and Greek could be extended with additional corpora and language models to handle Spanish, French, Italian, Latin, Dutch, Russian and Swedish as well. The resulting solver was not perfect, particularly when dealing with characters that appeared very infrequently, but it was accurate enough to recover almost every substitution automatically.

    The challenge’s feedback mechanism then filled the remaining gap. Knowing that, for example, 21 out of 22 symbols were already correct transformed the final correction from cryptanalysis into a much smaller linguistic problem. For languages I knew I could handle this manually, while for the others ChatGPT was useful for identifying the few words that did not quite fit.

    In the end, this combination of classical frequency analysis, multilingual statistical models, automated search and a small amount of human correction was enough to solve all 20 rounds and obtain the flag.

    I particularly enjoyed Rune Decryptor because it demonstrated something that is easy to overlook when working on modern cryptography challenges: classical cryptanalysis becomes much more interesting when the problem is not simply whether a cipher can be broken, but whether the process can be made reliable enough to work automatically across completely different languages.