# The Ghostly Witch We're presented with a list of clues. Solving them gives the following answers: | **Clue** | **Answer** | | :----------------------------------------------------------: | :--------: | | Professional reviewer [6] | CRITIC | | `???????` [7] | `???????` | | Type of chemical bond [5] | IONIC | | Vegetables that make you cry [6] | ONIONS | | - | | | `???????` [7] de | `???????` | | Helium filled party decoration [7] | BALLOON | | Close-fitting knitted cap [6] | BEANIE | | Number represented by giga prefix [7] | BILLION | | Noble gas often used in signs [4] | NEON | | - | | | Disney film featuring a genie [7] | ALADDIN | | Place for cold cuts [4] | DELI | | `???????` [7] | `???????` | | Without clothes [5] | NAKED | | - | | | Blood vessel type [6] | ARTERY | | Home planet [5] | EARTH | | Chemical compound with two alkyl groups joined to oxygen [5] | ETHER | | Very energetic [5] | HYPER | | Beats rock in a hand game [5] | PAPER | | Incisors or molars [5] | TEETH | | `???????` [7] | `???????` | | Greek letter often used to represent an angle [5] | | | - | | | County east of London [5] | ESSEX | | Formally remove from school [5] | EXPEL | | Google phone [5] | PIXEL | | Fairy [5] | PIXIE | | Seductively attractive [4] | SEXY | | `?????????` [9] | `?????????` | | II^V [5] | XXIII | | - | | | Bean used for chocolate [5] | CACAO | | `?????????` [9] | `?????????` | | Place for betting chips [6] | CASINO | | Used in crafting minecraft torches [4] | COAL | | Physicist Newton [5] | ISAAC | | MRI or CT [4] | SCAN | | Knuckles’ blue counterpart [5] | SONIC | | - | | | Cells that line the bronchus [5] | CILIA | | Vote into office [5] | ELECT | | `?????????` [9] | `?????????` | | Forbidden by law [7] | ILLEGAL | | Slanted text [6] | ITALIC | | - | | | False religion [4] | CULT | | The L of BLT [7] | LETTUCE | | `??????` [6] | `??????` | | Offspring of donkey + horse [4] | MULE | | Informal term for stomach [5] | TUMMY | | - | | | Not your ally [5] | ENEMY | | `??????` [6] | `??????` | | Mickey, e.g [5] | MOUSE | | Touch or taste [5] | SENSE | | - | | | Place for duels in ancient times [5] | ARENA | | Mom or dad [6] | PARENT | | Gift, or right now [7] | PRESENT | | North Pole resident [5] | SANTA | | `????????` [8] | `????????` | | - | | | Damp or wet [5] | MOIST | | Keeps an eye on, or part of a gaming set up [8] | MONITOR | | Constallation featuring a belt [5] | ORION | | Capital of Ontario [7] | TORONTO | | `???????` [7] | `???????` | The answers are also in alphabetical order, which helps to confirm some of the trickier clues. You might notice that for each "group" of answers, a lot of letters are shared, in fact there are exactly 7 letters used to make up all the words. This may remind you of the New York Times Game, [Spelling Bee](https://www.nytimes.com/puzzles/spelling-bee), where you need to spell as many words as possible using only 7 letters. The general theme about witches, which links to "spells" -> spelling acts as a confirmer for this. An important part about this game is that there is a central letter that all words must contain. If we take all the groups, we can take the letter that they share to get `INDEXCLUENO`, indicating that we should index by the number of clues somehow. But what do we index into? This is where the flavourtext and the question marks come in. The flavourtext directly hints at Scripps' Spelling Bee, of which there is a Wikitionary page for a [list of winning words](https://en.wiktionary.org/wiki/Appendix:Scripps_winning_words) that have been used. We go back to the groups of letters and notice that we can spell a winning word using our 7 letters. The alphabetization and length also help confirm these. | Group | Winning Word | Number of Clues | Extracted Letter | | :---: | :----------: | :-------------: | :--------------: | | 1 | incisor | 3 | C | | 2 | abalone | 4 | L | | 3 | knaidel | 3 | A | | 4 | therapy | 7 | Y | | 5 | syllepsis | 6 | P | | 6 | canonical | 6 | I | | 7 | elegiacal | 4 | G | | 8 | lyceum | 4 | E | | 9 | euonym | 3 | O | | 10 | transept | 4 | N | | 11 | torsion | 4 | S | Finally, after indexing by CLUE NO (and ignoring the question marks since they aren't really clues), we get the answer, **CLAY PIGEONS**. ## author's notes This puzzle was the first out of the "puzzles" I wrote. It's also probably my favourite puzzle out of the 5, given that the theme all fits together quite nicely and would probably most likely appear in a puzzle hunt due to the vagueness (although not as vague for accessibility reasons). Originally the puzzle was a lot messier however, as the construction for this was rather restricted due to the limited about of winning words in the Scripps' spelling bee, even less that only used at most 7 unique letters. It would definitely have been more elegant to have those words share the central letter as well, but some bigrams were just not possible. I had to resort to using words with 6 or less unique letters and adding an extra letter artificially to make it work. The original cluephrase was a lot messier too. Due to construction restraints I wanted to make the cluephrase as short as possible, which ended up being `INDBYWORDCNT`, which in hindsight was an awful idea due to the fact that, even with all the letters, it's not entirely obvious what it's asking you to do, so one tricky clue could just be a singular point of failure. Special thanks to Rafa for helping with the reconstruction. # X/6 We're presented with what looks like a game of Wordle, along with clues that go along with it. Solving the clues gives us: | Clue | Answer | | :---------------------------------------------------------: | :----: | | Often balanced with rewards | RISKS | | McDonalds burger containing poached protein, found in India | MCEGG | | C4H9 | BUTYL | | Red breasted bird | ROBIN | | XX - II | XVIII | | Shade of purple, or a plant | LILAC | Using the clues we deduce that the solution to the wordle is MASYU, a popular grid puzzle that features black and white circles. We then solve the wordle board like a masyu, with green squares as white circles, and yellow squares as black circles (masyu board is not solvable otherwise). ![](https://s3.hedgedoc.org/demo/uploads/57a7e16a-3962-4bfb-956d-0219a31f2a7c.png) Solving the board and looking at the letters that do not go through the path when read in reading order spell the solution, **SCENIC** ## author's notes This short and sweet puzzle was inspired by reading [this post](http://www.mit.edu/~dwilson/puzzles/puzzlewriting.html) by MIT on how to write puzzlehunt puzzles, specifically section 2bii, which mentions: > Oyster Card came from realizing that the circular signs indicating London Tube stops were reminiscent of the white and black circles in a Masyu (a type of logic puzzle). While Masyu is generally played on a grid, it could potentially be superimposed on a complicated graph...such as the Tube map. which made me think of what other things could be turned into Masyu puzzles, and eventually Wordle came to mind. Construction for this was not too bad, although "MCEGG" was rather constrained due to requiring "MCE??". In hindsight, I probably could have kept generating Masyu's until a very nice one appeared, but I was probably too tired. Also, the original clue for "XVIII" was "Adult age in Rome?". # generic pen and paper puzzle The grids are morse nonograms, where the pattern of dots and dashes are indicated at the edge. A dot is considered either a dot or the end of a line. The solved grids look like: ![](https://s3.hedgedoc.org/demo/uploads/5362ef57-9f83-4fa2-99e8-c7288e468aa9.png) ![](https://s3.hedgedoc.org/demo/uploads/45fddb33-a1c8-41ed-ae2d-5d2e63d7723f.png) which can be interpreted as pigpen to get "BIG PACIFIC REEF". Using the enumeration at the bottom (3 7 4 -> 5 7 4), we can deduce the answer is **GREAT BARRIER REEF** ## author's notes Originally this puzzle's idea was going to be a lot more complex, by trying to use diagonal lines + dots in the middle of edges so I could get the whole pigpen alphabet, but then I realised it was far too complex and since I wasn't sure if it was solvable, we have the current version. This puzzle was also originally called "Always" in reference to the weekly, but was later changed to "generic grid puzzle", "generic morse puzzle" and then finally to "generic pen and paper puzzle" (since mentioning morse in the title was too easy) # cat pictures Each of the images represents a [HTTP status code](https://en.wikipedia.org/wiki/List_of_HTTP_status_codes) in the 4xx range. The full names are in alphabetical order to help with identification. | HTTP Response | HTTP Status Code | Colour of cat in image | | ------------------ | ---------------- | ---------------------- | | Conflict | 409 | Green | | I'm a teapot | 418 | Orange | | Method Not Allowed | 405 | Indigo | | Not Found | 404 | Violet | | Payment Required | 402 | Red | | Unauthorized | 401 | Yellow | | URI Too Long | 414 | Blue | We can also notice each cat is a colour of the rainbow, which gives us a sorting. Converting the xx part of the 4xx code with A1Z26 gives us our final answer, **BRAINED** | Cat colour | HTTP Status Code | Extracted Letter | | ---------- | ---------------- | ---------------- | | Red | 402 | B | | Orange | 418 | R | | Yellow | 401 | A | | Green | 409 | I | | Blue | 414 | N | | Indigo | 405 | E | | Violet | 404 | D | ## author's notes When designing the puzzles half I did want to include a generic ISIS (Identify, Sort, Index, Solve) puzzle, and so inspired by [http.cat](https://http.cat) and [cat's shouldn't](https://twitter.com/catshouldnt), this puzzle was born. I definitely wanted to include codes 404 (not found) and 418 (I'm a teapot) also, excuse the poorly paint-3d drawn cats # Alice and Friends This puzzle is a standard logic puzzle, however some of the clues contain weird details about the puzzles that aren't mentioned anywhere. The key is that the puzzle is referring to [enigmatics weeklies](https://weeklies.enigmatics.org), more specifically, season 4W (as hinted by the february 2023 clue). Other hints include the puzzle names (the verdict, skeleton, eatery, shop, which all reference particular weeklies in the season), and the flavortext referring to the title of the weekly, where Alice is sending Bob even more weeklies. The logic process will not be explained here due to length constraints, but the finished puzzle looks like this: ![](https://s3.hedgedoc.org/demo/uploads/013a71dc-b44f-4b36-8f1b-70f6c43d551c.png) We can use the house numbers to give an ordering for each person, and then use the puzzles solved to index into both name and favourite puzzle type to extract the answer, **BEAVERSAROMA** ## author's notes A fairly standard puzzle, with an enigmatics twist. From what I heard, most people did not solve this one due to it taking a long time + not getting the aha immediately. I also would have liked a nicer extraction method (such as making which puzzles solved actually matter) but I also wanted to keep the total number of puzzles solved fairly low so the logic could be more constrained. Originally there were also a lot less clues directly involving the weeklies, and so eventually to prevent solving without actually understanding, more were added. # Two Time Pad ```python= import os PUBLIC_MESSAGE = b"Hello! This is a real message. However, obtaining this won't give you anything; you need to find the secret message" SECRET_MESSAGE = b"This is a fake message. The output produced is by running this script with the real message." one_time_pad = os.urandom(len(PUBLIC_MESSAGE)) # generates a completely random key to be used in the XOR cipher def xor(key, plaintext): # performs a XOR operation on the key and the plaintext return bytes([keybyte ^ ptbyte for keybyte, ptbyte in zip(key, plaintext)]) print(xor(one_time_pad, PUBLIC_MESSAGE).hex()) # outputs the result of xor(one_time_pad, public_message) as a hex string print(xor(one_time_pad, SECRET_MESSAGE).hex()) ``` The challenge implements a One-Time-Pad encryption, however the "one-time-pad" is used twice, once to encrypt a known message, and another time to encrypt the secret message we are trying to find. We begin by noting four useful properties of the XOR operation (denote XOR as $\oplus$): 1. Self-inverse, $a \oplus a = 0$ (i.e. if you xor anything with itself it will create a null byte string) 2. Commutativity, $a \oplus b = b \oplus a$ (i.e. you can do XOR in any order) 3. Associativity, $(a \oplus b) \oplus c = a \oplus (b \oplus c)$ (i.e lemon) 4. Identity element of 0, $a \oplus 0 = a$ (i.e. xoring anything with a null byte string will leave the original string unchanged) The key idea here is that given the equation , $m \oplus k = c$, where $m$ is our known message, $k$ is the unknown key, and $c$ is the ciphertext, we can xor by $m$ on each side. We'll use the properties above to show that we can use this to recover $k$. $$ \begin{eqnarray} m \oplus (m \oplus k) &=& m \oplus c\\ (m \oplus m) \oplus k &=& m \oplus c\\ 0 \oplus k &=& m \oplus c\\ k &=& m \oplus c \end{eqnarray} $$ Hence we have shown that we can recover $k$ by xoring our known message with the ciphertext. We can then recover the secret message by xoring our newfound $k$ with the other ciphertext. Solve script: ```python= ct1 = bytes.fromhex("bb3652a6ccc9729d566062032f38015a24c66639f8ed9e0ddf97183c08584cf845a5a7fa76d0ec819f2628db218430fb29b810d46f9997b1a20663801134a0e44134ef0972842dffae55cd5913b8f922a429b921184062d19262c94eaf81cbbb8586e0615211f4f7409a2a40ec0804e3b7fde9") ct2 = bytes.fromhex("b03c50add18926bc5268654a2925521b6bda233efda39701c283592f051f1f9059b7a1fe76d6e0cc95372fdb2f8f77b51af0019c6784c4b1a81a648d5e26e9fc4171f24673cb6ce2a243c85909ffab71fd028940226d27e5f758e7229da1e08c") def xor(key, plaintext): return bytes([keybyte ^ ptbyte for keybyte, ptbyte in zip(key, plaintext)]) message = b"Hello! This is a real message. However, obtaining this won't give you anything; you need to find the secret message" key = xor(message, ct1) print(xor(key, ct2)) ``` which reveals the answer, **DEATH PENALTIES** # Modular Technology ```python= from Crypto.Util.number import getPrime SECRET_MESSAGE = b"This is a fake message. You need to use the output to recover the real message." assert len(SECRET_MESSAGE) == 79 def to_bytes(x): # converts number to 256 bytes (base 10 -> base256) return x.to_bytes(256, "big") def from_bytes(x): # converts 256 bytes to number (base256 -> base 10) return int.from_bytes(x, "big") p, q = getPrime(1024), getPrime(1024) # generate two large primes modulus = p * q # multiply them together exponent = 3 print(modulus) print((from_bytes(SECRET_MESSAGE) ** exponent) % modulus) # encrypt using RSA, by doing pt raised to the power of exponent (3) modulo the modulus ``` In this challenge, we are given an implementation of the RSA cryptosystem, where the message is The RSA cryptosystem is based on the security of factoring integers (i.e. it is very hard to, given some arbitrary large number, deduce all of its prime factors). In the RSA cryptosystem, we pick two primes, $p, q$, and multiply them together to get a number known as the **modulus** $n$. Calculating $n$ is easy, but finding $p, q$ only given $n$ is hard. The encryption is as follows, convert the message into a base 10 number, raise it to the power of $e$, and take this number modulo $n$, to get a ciphertext $c$. The modulo function will subtract (or add) $n$ from $c$ until it is between 0 and $n-1$ The "hard" part of this is that modulo is an infinite-to-one function, i.e. there are infinite many inputs that will result in a particular output. This means that we cannot simply take the $e$'th root... unless we know that $m^{e} < n$, in which case we can say that the modulo function is not applied, as the ciphertext is already between 0 and $n-1$. Can we show that this holds true for this challenge? We can do it in a "trivial" way by simply looking at the lengths of the ciphertext and modulus, and seeing that the ciphertext is quite a bit smaller than the modulus. We can also show this in a mathematical way, as we are given the length of the secret message and the modulus. The secret message is 79 bytes = $2^{79 * 8} = 2^{632}$ and when encrypted the ciphertext will be at most $2^{632 * 3} = 2^{1896}$. The modulus is the product of two primes that are $2^{1024}$ in size, so the modulus will be around $2^{2048}$, which is significantly greater than the size of the ciphertext. Therefore, we will just take the 3rd root of the ciphertext and we will have our message. ## Solve script: ```python= from gmpy2 import iroot ct = 454147422122408638236729228778619151840745110573901635266653412884827448174696657067161863712007472045862643664187179466863186606693544670825981955322220612714094448031487713801162618522078593851990811307326192227448461688364355030506264012718298715849972685746984958365293426324609917961631777964113223482455400203888938149651008187748240438990178348789806411004166592944184851235951272579621706914933443403222482532359174060018780965662299105233528835083965863568231382797401795357662064127581799775007005491721985455877597766465099098235202614183072688947734662600789 e = 3 message = int(iroot(ct, 3)[0]) def to_bytes(x): return x.to_bytes(256, "big") print(to_bytes(message)) ``` to get the answer, **ASTIGMATISM** # Electricode ```python= from Crypto.Cipher import AES import os SECRET_MESSAGE = "THISISAFAKEMESSAGEYOUNEEDTOUSETHEOUTPUTTOGETTHEREALSECRETMESSAGE" alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" assert all([char in alphabet for char in SECRET_MESSAGE]) # makes sure the only characters in the secret message are in the alphabet key = os.urandom(16) # generate a random key cipher = AES.new(key, AES.MODE_ECB) # defines the AES cipher using ECB mode for char in SECRET_MESSAGE: # encrypt the secret message character by character print(cipher.encrypt((char + "\x00" * 15).encode()).hex()) # encrypt the character using AES-ECB mode ``` Researching into [ECB mode](https://en.wikipedia.org/wiki/Block_cipher_mode_of_operation#Electronic_codebook_(ECB)) reveals that "ECB encrypts identical plaintext blocks into identical ciphertext blocks". In our case, each block is one character long, so this turns into a monoalphabetic substitution cipher (where one character is substituted for another character, or in our case, a unique hex string). There are many tools for solving them, including [quipquip](https://quipqiup.com) and [guballa's solver](https://www.guballa.de/substitution-solver), so all we need to do is get our output into a format it likes, by replacing each one of our hex strings with a unique letter. ## solve script ```python= output = """ ... replacehere with output """ output2 = output.splitlines() alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" for i, x in zip(alphabet, set(output2)): output = output.replace(x, i) print(output.replace("\n", "")) ``` Plugging the output of this into one of the substitution ciphers mentioned above will give us our answer, **OVERSTAFFED** # magic random ```python= import random def lcg(state, multiplier, increment, modulus): return (multiplier * state + increment) % modulus def xor(key, plaintext): # performs a XOR operation on the key and the plaintext return bytes([keybyte ^ ptbyte for keybyte, ptbyte in zip(key, plaintext)]) def to_bytes(x): # converts number to 4 bytes (base 10 -> base256) return x.to_bytes(4, "big") def from_bytes(x): # converts 4 bytes to number (base256 -> base 10) return int.from_bytes(x, "big") # reads the content of the secret.png file SECRET_IMAGE = open("secret.png", "rb").read() modulus = 0xfffffffb multiplier = random.randrange(0, modulus) # generates multiplier, increment, state which are in the range of 0 - p increment = random.randrange(0, modulus) state = random.randrange(0, modulus) # generates a one time pad using the LCG to generate bytes otp = b"" while len(otp) < len(image): state = lcg(state, multiplier, increment, modulus) otp += to_bytes(state) print(xor(SECRET_IMAGE, otp).hex()) ``` The goal for this challenge is to recover the one-time-pad used to encrypt a png file. The thing that makes it possible is that this one-time-pad is generated using an insecure pseudo-random generator, known as a [linear congruential generator](https://en.wikipedia.org/wiki/Linear_congruential_generator). There is a [lot of literature online](https://tailcall.net/posts/cracking-rngs-lcgs/) about how to break this sort of PRNG, where the parameters are not known, however we are not given any of the states. We are given $p$ however, and we can also check that it is a prime number. To recover the states, we must use the other bit of information we are given; that the file being encrypted is a PNG file. PNG files have 16 bytes at the very start which are always the same (this can be googled or checked by just looking in some sample PNG files). [This](https://www.nayuki.io/page/png-file-chunk-inspector) is also a good link to view chunk stuff on PNGs. This 16 bytes is comprised of: - 8 bytes of the PNG header `89504e470d0a1a0a` - 4 bytes of the IHDR chunk length `0000000d` - 4 bytes of the IHDR chunk type `49484452` (the IHDR chunk must always be first, which is why even though you won't find these bytes in [here](https://en.wikipedia.org/wiki/List_of_file_signatures) for example, these are still 16 fixed bytes.) Performing the xor gives us (in hex), `0e14a73595e566cbab909829ba1e0375` In total, this gives us 4 consecutive states of the LCG (`[236234549, 2514839243, 2878380073, 3122529141]`). We can then write some relations (with unknown variables $a, b$), noting that for two consecutive states $s_1, s_2$, we have the relation $a * s_1 + b = s_2 \bmod p$. $$ \begin{eqnarray} 236234549*a + b = 2514839243\\ 2514839243*a + b = 2878380073\\ 2878380073*a + b = 3122529141\\ \end{eqnarray} $$ Solving these equations (mod 4294967291) using [dcode's solver](https://www.dcode.fr/modular-equation-solver), or just plugging the states into the scripts provided in [here](https://tailcall.net/posts/cracking-rngs-lcgs/) will give the following values for $a, b$ $a = 2755772648, b = 3223642598$ Then, since we can generate the OTP used (by setting the last state to be the current state and rerunning the script by setting a, b values), we XOR the output with the OTP to get the image. ![](https://hackmd.io/_uploads/rk0Lw8Ujn.png) ## solve script ```python= from Crypto.Util.number import * def xor(key, plaintext): # performs a XOR operation on the key and the plaintext return bytes([keybyte ^ ptbyte for keybyte, ptbyte in zip(key, plaintext)]) output = bytes.fromhex("8744e97298ef7cc1ab909824f356472717bbecaeddca0abf187f30adfa7bd8a438eb099b12466e4cdfd515e98d9f0bc51fa55e59d4e2a963a706261c1c0fcceaac2634794387f588ba8fa62c4b168b7f45de2c3954301c4653c8d2cfab246fd6adc212399eddd999b1d748541c8532aa3c0f22ddc99a0fd4958e836a87d5c12f563decdb33211a14389b2a945995829b07eb0924480d4f665835d66d61a4785a8964d10d50d7f6f690f5965d38ed141c4838c76a81b9a0df48939d17e59f0d82139e5110856d7033811de19f68b1f51e5aa980d2cb60ba0c5957d5384a1fc6837ead7bf869ddacffe71774a1a66d310cd11b9a123502980a9dac32469302fb234ece786c5d066a5dc3823585d7927e53f405294a2bc8686a6396337c2c9ad6736e69295e94e8e320dcebb2c7ea541e8457d61f43c1dabdaa3d31ea94950f8afb0f7937f8cbb11f807ea247db91e5fa1fd3a9686a01e62127e286811b1e42a6c6321e6273be69a8053c2e0c57587e6b96f8d595d4ebb2db4589e60e18931d7acacbb9146d03c7525ad9910a41d79a7b1e23be3fa8673a49d66c9b0017a62dcf2594dd843390419898ce458256898e49615b8cf6257b2b0b2fcde8639f0b879c745aeabf3492ddfd67639bfc2c91713f430d268da2e851d4d6e5fa72db736b10ca0a8fec3bc035eb086fa3903b523cf15d6241c19ece36008bfd5d80814744b17f8b1da27082ad01709044dd96e7adb91fee5e3c41d28288847be8b412f31b4e83de16e1f49eb366d549c4fa6a0cdacdf3362c620859754d225b5a161d1b90271accde0cab1f243308c39bfc8ec3ba3b65bb5b21e4fc9089c7d4f13cf5a5f3f53ea56c98620ad45b51c3386d66d9a8ed3911fa6af786917e900aa57a6a599cd2c7b1e9e9f1fdeefceef14c8607cdf327ec7386cf21e1ae1779d02f6d45d1d32b290cfac9089524f6178986228be808629d2221984b51267bb5e28ac363422393a710d6f0097a9e06e647222f27c09d0de49578f4878000f6cd152ebb83b5d89adaf3df4fa837ba6073d86dedc8ea9de93c5c1036dad9ec0513e91ef80a8e2bf96466706f893c63cca1b5fc32960fcaae91333186595e5a127d75c667f688633a2380219810ae5b419e11b4ac0b17959628399f2b30e44bc09b9bdb6b625efac71bb9d22bae3c08508cbde105ef505217db69a37dd708190a949751962d30a66b835df4acadc68f5aaef0c014f54871af6bed7da6fe86b3b84dbe3202d9f0d12ae880a4ecdfc9ed92a69d97775e7b4d94b03f1fbfccd3b591b5e21b84d09d9e4990aa65159649e9b04ae8bb0e47c2f045b6b45d00a37a37e3d91f813a42e58114a6c0d86e25a5b313e589fa91d8") p = 0xfffffffb knownbytes = bytes.fromhex("89504e470d0a1a0a0000000d49484452") s = xor(knownbytes, output) states = [bytes_to_long(s[i:i+4]) for i in range(0, 16, 4)] # most of this code below is stolen from the link https://tailcall.net/posts/cracking-rngs-lcgs/ def crack_unknown_increment(states, modulus, multiplier): increment = (states[1] - states[0]*multiplier) % modulus return modulus, multiplier, increment def crack_unknown_multiplier(states, modulus): multiplier = (states[2] - states[1]) * modinv(states[1] - states[0], modulus) % modulus return crack_unknown_increment(states, modulus, multiplier) def egcd(a, b): if a == 0: return (b, 0, 1) else: g, x, y = egcd(b % a, a) return (g, y - (b // a) * x, x) def modinv(b, n): g, x, _ = egcd(b, n) if g == 1: return x % n def lcg(state, multiplier, increment, modulus): return (multiplier * state + increment) % modulus def to_bytes(x): return x.to_bytes(4, "big") p, a, b = crack_unknown_multiplier(states, p) otp = to_bytes(states[0]) state = states[0] while len(otp) < len(output): state = lcg(state, a, b, p) otp += to_bytes(state) open("secret.png", "wb").write(xor(otp, output)) ``` which gives us our solution, **FLASH FLOODING** # Vigenere CBC ```python= import random alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" def generate_iv(block_size): # generates a random string of block_size letters # to be used as the initialization vector for the encryption return "".join(random.choices(alphabet, k=block_size)) def add_key(key, plaintext_block): key_idxs = [alphabet.index(key_char) for key_char in key] pt_idxs = [alphabet.index(pt_char) for pt_char in plaintext_block] # adding the indexes of the key and plaintext together ct_idxs = [(key_idx + pt_idx) % len(alphabet) for key_idx, pt_idx in zip(key_idxs, pt_idxs)] # converting it back into an alphabetical string return "".join([alphabet[idx] for idx in ct_idxs]) def vigenere_block_chaining_encrypt(key, plaintext): key_length = len(key) # pads the plaintext plaintext = pad(key_length, plaintext) # generates an iv iv = generate_iv(key_length) # splits the plaintext into blocks of length key_length blocks = [plaintext[i:i+key_length] for i in range(0, len(plaintext), key_length)] previous_block = iv ciphertext = "" # refer to https://www.educative.io/answers/what-is-cbc for block in blocks: # adds the previous block # for the first block this will be the random iv block = add_key(previous_block, block) # adds the vigenere key (the block cipher encryption) previous_block = add_key(key, block) # adds the output to the final ciphertext ciphertext += previous_block # give back the plaintext with the iv return iv, ciphertext def pad(block_size, plaintext): # pads the plaintext with X's until the length is a multiple of block_size plaintext += "X" * (-len(plaintext) % block_size) return plaintext SECRET_MESSAGE = "THISISAFAKEMESSAGEYOUNEEDTOUSETHEOUTPUTTOGETTHEREALSECRETMESSAGE" key = "AFAKEKEY" assert len(key) == 8 assert all([char in alphabet for char in key]) assert all([char in alphabet for char in SECRET_MESSAGE]) iv, ciphertext = vigenere_block_chaining_encrypt(key, SECRET_MESSAGE) print("iv =", iv) print("ciphertext =", ciphertext) ``` Each block of ciphertext is done by adding three components: - the plaintext message block $m$ - a fixed 8 character key $k$ - the IV $iv$, which is either the IV provided at the start (if it's the first block), or the previous block of **ciphertext**. So, each block can be written as $m + k + iv$. Then, the main things to realise for this challenge are - Vigenere in ECB mode can be broken (using standard solvers available online such as [guballa's solver](https://www.guballa.de/vigenere-solver)), or using frequency analysis methods. - The operation for adding the key and IV are the same; Our goal here is to try and turn this encryption into vigenere in ECB mode. Recall that the vigenere cipher is simply adding a constant key periodically, so using the same names as before, each ciphertext block would be $m + k$. We know from above that if we can convert the encryption into ECB mode, we can break it using online tools. Now it should be fairly visible on how to proceed. $iv$ will always be known, since we have the initial IV and we have the ciphertext. This means that we can, for each ciphertext block $m + k + iv$, subtract the corresponding IV to get $m + k + iv - iv = m + k$, and this will reduce it to vigenere-ECB mode. Solve script: ```python= block_size = 8 ct = "WYZCAZIXERPEDAJTTMZRCHVVOIEWJXCFMCGHDHKDDMQWDKVRVCCAWNHOWDXHTUTCXPGLDXVQXEIESEITZUQIYHTTZJWKZVUTXKCHLWOCFWGVRQMPFILDXIULREEPMOWJFYGZJFYYFNMRCQWUEZQSOWYITZTWJVMWEACAQJKJLBMOWNSWSMRWCSFZDCOWIUZYNSDCUAXEYYFTRGFMTSVKZMHKSOTOULOGOLGMGRMOVGLTDDIOWBNILNADKNBQRMHXFMGUBLOWUNECFVALJGRYMLEHDMPYDQMRNDFZKCYBFGPMJUZFECEQKMAJYUOJVEBJMCHMSVOTNDXREBMIYNGEFCGPTIMWZVTHHQHANYEWVCJNYTCJKCNRCLEHROHKCORLATGHSKIE" iv = "XCVUSTKK" alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" def sub_key(key, plaintext_block): key_idxs = [alphabet.index(key_char) for key_char in key] ct_idxs = [alphabet.index(ct_char) for ct_char in plaintext_block] pt_idxs = [(ct_idx - key_idx) % len(alphabet) for key_idx, ct_idx in zip(key_idxs, ct_idxs)] return "".join([alphabet[idx] for idx in pt_idxs]) blocks = [ct[i:i+block_size] for i in range(0, len(ct), block_size)] out = "" prev_block = iv for block in blocks: out += sub_key(prev_block, block) prev_block = block print(out) ``` This can be plugged into the vigenere solver linked above to get the answer, **CELTIC AESTHETIC** # meta The meta is unlocked at 7 solves, and the image changes based on the number of solves, with extra hints added the more solves there are. ## 7 solves ![](https://hackmd.io/_uploads/r1IdN-tsn.png) ## 8 solves ![](https://hackmd.io/_uploads/HJUuN-Ksn.png) ## 9 solves ![](https://hackmd.io/_uploads/SJ8dE-Kjh.png) ## 10 solves ![](https://hackmd.io/_uploads/ry8OE-Ksh.png) The feeder answers are: ``` CLAY PIGEONS SCENIC BRAINED GREAT BARRIER REEF BEAVERS AROMA FLASH FLOODING OVERSTAFFED CELTIC AESTHETIC DEATH PENALTIES ASTIGMATISM ``` The idea is to pair the answer such that **ciphers appear in the places where there are blue squares**. An example is CLAY **PIG**EONS + DEATH **PEN**ALTIES -> CLAY(PIG PEN)ALTIES. Each of the ciphers also appears at a different index relative to the first word, and so can be uniquely placed into the image shown. The pairings are as follows: ``` SCENIC + ASTIGMATISM -> SC(ENI GMA)TISM GREAT BARRIER REEF + FLASH FLOODING -> GRE(ATB ASH)FLOODING CLAY PIGEONS + DEATH PENALTIES -> CLAY(PIG PEN)ALTIES CELTIC AESTHETIC + BEAVERS AROMA -> CELTI(CAE SAR)OMA OVERSTAFFED + BRAINED -> OVERST(AFF INE)D ``` We can then fill this into the image and use the numbers to read off the meta answer, which is **STOP EATING BYTES**. # a few random thoughts When making a weekly, my one persistent issue with them is that they are almost always fully linear, i.e. you must solve everything in a particular order. This is annoying for a couple of reasons: - you have no way of progressing without hints if you get stuck on a puzzle (commonly referred to as bouncers) - you aren't even able to look at the rest of the weekly until you solve the puzzle you are on - you must solve all puzzles to complete the weekly, no matter how annoying/tedious/~~stupid~~ the puzzle looks While the third one is not as much as an issue as it somewhat makes sense that most of the puzzles should be solved, combined with the first issue, sometimes weeklies, despite the good quality of the puzzles, can be very annoying to solve. With this weekly, I styled it like a puzzlehunt for a few reasons - to address issues 1 and 2 above - due to the inclusion of technical puzzles, which i knew would be slightly more inaccessible than the other puzzles, i did not want to force these to all be solved - i wanted a metapuzzle Admittedly, I don't think the metapuzzle is super interesting, and if I were to do this another time, I think I would try and find a more interesting way of using the answers (maybe something more semantic rather than orthographic). A nice thing about having the technical puzzles was that the meta feeders could be very flexible. Making the website for this was a bit of a pain. I decided to use the same layout as my personal website, with a few stylistic changes to keep things simple. One major issue however is that checking answers with a purely static site is annoying. I opted to do what most webriddles do; check for the existence of a particular page. Basically all of the answer checking + meta checking is done using existence of pages as an alternative to an actual webserver, with a few extra things (such as repeated hashing) for increased security. The meta checking code is particularly messy, mainly because I chose to store all answer combinations (and also I wrote it about 2 days before the weekly released) One more thing on the about section, it was made very long to ensure almost complete transparency in terms of what is part of the puzzle. Often I find it rather frustrating to distinguish between not knowing how to solve a puzzle and not having a required piece (hidden files, checking some random place) due to it not being well hinted, e.g. Thanks to all who solved/attempted my weekly. Hopefully you enjoyed it, despite the change in format + puzzles compared to other weeklies. Anyway, I doubt I'll write something like this in the future since I'm out of ideas right now. Maybe next year.