MagpieCTF 2025

DentonDenton
14 min read

Back in November, when I competed at CyberSci Regionals, I met and spoke with a few students from UofC, some of whom were part of The Cybersecurity Club - UCalgary. For the past five years, they have hosted an annual CTF known as MagpieCTF. They invited my team and I to sign up and compete this year if we were interested.

The CTF was open to any high school or post-secondary students attending school in Canada, and it was a 24-hour event this past weekend, February 22nd to 23rd, starting at 4 pm. I wasn’t able to start until Sunday morning, but I tried my best to make up for the lost time and just have some fun. I was able to solve 5 challenges before the end of the competition and as always, I have put together a walkthrough to share my solutions of those challenges.

Big thank you to The Cybersecurity Club - UCalgary who organized and hosted this amazing event, it was quite challenging but a lot of fun. As well as thank you to my teammate Caleb Lerch, for two people who could only compete one day each, I’d say we did pretty good.


Solved Challenges

  • Misc:

    • Sanity Check - Teammate solved for 50 Points
  • Web:

    • hidden-flag - Teammate solved for 400 Points

    • cookie-trail - Teammate solved for 510 Points

  • Forensics:

    • Cat Pics - Solved for 560 Points

    • Deed of Desperation - Teammate solved for 740 Points

    • Mansion-Recovery - Teammate solved for 740 Points

  • Cryptography:

    • All Ends Same - Solved for 670 Points

    • Imp3rf3ct - Solved for 690 Points

    • Grey Area - Solved for 730 Points

    • Inverse Converse - Solved for 740 Points

* I can only share my solutions as I don’t know what my teammate did to solve their completed challenges


Challenge Solutions

Cat Pics

New email from cors@nypd.gov:
We were sent a USB to the precinct from someone who claims to be from within the Krypto Foundation, their claim is that it contained leaked messages from within the Company. However it appears to contain unrelated information, it could be a prank, try and make something of it.
Edward Cors - NYPD

The provided image file from the email is shown below, nonsuspiciousimage.png. It doesn’t include what was expected like the challenge description had mentioned, but using some tools could reveal any hidden information.

My first step for working through this challenge included trying a couple different steganography tools to check for any possible hidden information. Since this image file is a PNG I tried the tool zsteg first, which worked and returned quite a lot, including a link to another file (outlined in the white).

Taking the link and using the command wget allowed me to download the file to analyze further.

The file extension showed it to be a JPEG file, so I first confirmed it by using the command file. From there I used another steganography tool to check for anymore possible hidden information again, but not having a password to enter into steghide wasn’t allowing me to extract any data.

My next idea to find any other info besides opening the image included outputting the hexdump of the file using the command xxd, piping the command to include head -20 allowed me to focus on fewer lines at once revealing the flag in the ASCII representation column on the right side of the output.

Challenge flag: magpieCTF{p1cture36_In_PicTuR37}

All Ends Same

Christina Krypto's Diaries:
"Despite our differences, Professor Richard Hash and I are working towards the same core goal: secure communication. The irony is that, in the end, cryptography—whether through his traditional methods or my evolving approaches—always comes down to the same fundamental “format”: transforming data into something unrecognizable to anyone who doesn’t have the key to decrypt it. Whether you use numbers, text, or any other form, cryptography boils down to one principle: securing information by making it unreadable without proper authorization. The formats may change, but the underlying idea remains the same—data must be protected from unauthorized access. It’s this constant need for security that binds our work, even when our methods diverge."

The files provided for this challenge included, ciphertext.txt, generate_cipher․py, private_key.pem, and public_key.pem. My initial thought for solving this challenge was to throw together a quick Python program that read those files, decrypted the ciphertext, and printed out the plaintext. To give me more of an idea of where to start for decrypting the ciphertext, I looked through generate_cipher․py for any possible info regarding the encryption process.

from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP
from base64 import b64encode as be

# generate RSA keypair
def generate_rsa_keys():
    key = RSA.generate(2048)
    private_key = key.export_key()
    public_key = key.publickey().export_key()
    return private_key, public_key

# first pad encrypted text with OAEP then base64 encode it
def encrypt(public_key, message):
    key = RSA.import_key(public_key)
    cipher = PKCS1_OAEP.new(key)
    ciphertext = cipher.encrypt(message.encode())
    ciphertext = be(ciphertext)
    return ciphertext

# Generate key
private_key, public_key = generate_rsa_keys()

# Read flag
message = open('flag.txt','r').read().strip()

# Encrypt flag
ciphertext = encrypt(public_key, message)

# Write keys and ciphertext to files
f=open("private_key.pem", "wb")
f.write(private_key)
f.close()

f=open("public_key.pem", "wb")
f.write(public_key)
f.close()

f=open("ciphertext.txt", "wb")
f.write(ciphertext)
f.close()

Reading the above code and comments lets us confirm and figure out some important info regarding the private key and ciphertext, helping us in moving forward in this challenge:

  • The provided files seem to be related to each other and will be useful in solving this challenge (only reason I include this point is because in the past, files have only been included to throw people off)

  • The ciphertext is encrypted with RSA using PKCS#1 OAEP

  • The ciphertext is base64-encoded

Knowing all this info, I was able to throw together and troubleshoot a small program that would decrypt the ciphertext:

from Crypto.PublicKey import RSA        # handle RSA keys
from Crypto.Cipher import PKCS1_OAEP    # handle OAEP padding
from base64 import b64decode            # handle base64-decoding

with open("private_key.pem", "rb") as f:      # opens private key file in binary mode
    privkey = RSA.import_key(f.read())        # reads file, imports RSA key, and stores it

with open("ciphertext.txt", "rb") as f:       # opens cipertext file in binary mode
    ciphertext = b64decode(f.read())          # reads file, decodes the b64-encoded text, and stores it

cipher = PKCS1_OAEP.new(privkey)         # create RSA cipher object using OAEP padding and the private key
plaintext = cipher.decrypt(ciphertext)    # using the just made cipher, decrypt the ciphertext to obtain the plaintext

print(plaintext.decode())    # print out the plaintext (flag)

After ensuring the necessary libraries are imported, it opens and reads the files, storing the required info before moving onto the decryption steps. To decrypt the ciphertext with the provided RSA key, we need to add PKCS#1 OAEP so it includes the decryption methods required. Lastly, it decrypts the ciphertext using that cipher and prints the plaintext to the terminal.

Challenge flag: magpieCTF{s4m3_g04l_d1ff3r3nt_f0rm4t}

Imp3rf3ct

Christina Krypto's Diaries:
"I’ve always believed in the power of innovation, but lately, I’ve started questioning what that really means. In my latest project, I created a system designed for efficiency above all else. It's simple, fast, and perfect for situations where speed is critical. But, as much as I tell myself it’s a breakthrough, I can’t shake the feeling that it’s a step back in some ways. Sure, it’s lightning fast, but the truth is, it’s not secur3. But I can’t ignore the fact that it’s not my best work. It’s a quick fix, not the kind of solution I’ve always strived for. But maybe that’s what the world needs right now. A little less perfection, a little more practicality. Professor Richard would spot this easily, a righteous person like him would sure stop me from publishing this imperfect invention of mine...hopefully."

For this challenge we were provided with two files, output.txt and source․py. The above description mentions that the system she created is fast but insecure, and looking at the provided Python and text files confirms this.

#!/usr/bin/env python3
from secret import flag
from Crypto.Util.number import getPrime,bytes_to_long
from Crypto.Util.Padding import pad
p=getPrime(512)
q=getPrime(512)
n=p*q
e=3
m=bytes_to_long(pad(flag,16))
hint=len(bin(m)[2:])


with open("output.txt", "w") as f:
    f.write(f"n = {n}\n")
    f.write(f"c = {pow(m,e,n)}\n")
    f.write(f"hint: {hint}")
    f.close()

Looking above at the provided variables, we know this challenge is an RSA encryption problem:

  • The public exponent e=3 is small, making it weak

  • p and q are randomly generated prime numbers

  • The RSA modulus n (provided in the output.txt file) is equal to p * q

  • The length of n is approximately 1024 bits because p and q are each 512 bits

In the text file, “hint: 255“, was listed at the bottom. I made the assumption that 255 was in reference to the size of m since typically for RSA, m is smaller than n.

Attack Method - Cube Root Attack

Since a small encryption exponent (e) is being used, if the plaintext message (m) is small enough and it’s smaller than n, m should equal cube root c. Meaning we could recover m easily with a simple Python script:

from Crypto.Util.number import long_to_bytes    # handle converting large number back into og string
import gmpy2                                    # handle math and large operations

# provided c value, encrypted message using RSA
c = 121097813989638138346058726004363325645055007528244635971148694227865847570370087018045267126514696616744216943938638911580999312232908265654986785392772317303161096833644079741186123067589808986604522528602623394273539018995365633

m = gmpy2.iroot(c, 3)[0]        # compute cube root of c

flag = long_to_bytes(int(m))    # convert back to readable bytes
print(flag.decode())            # print out the plaintext (flag)

It started by ensuring the necessary libraries were imported, then stored the given c value (found in the text file), and computed the integer cube root, storing in m. Lastly it converted the stored value to readable bytes and printed the flag.

Challenge flag: magpieCTF{3ff1c13nt_n0t_s3cur3}

Alternate Attack Method - Factorizing n

For this challenge I thought of another attack method for solving it and even though I didn’t use it for this challenge during the competition, I did want to explore the second method more afterwards. If by chance the last method didn’t work for you, this is another method that should work. Only thing to note though, it has more steps, requires a little more work, and is quite a bit slower.

First step for this attack method is to factorize n by plugging the value into a resource like factordb, which would then give you the values of p and q. If it’s too big of a number, factordb might not work and instead you will have to find another resource or tool.

Once you have the values of p and q, you need to compute the private key (d) by using the equation, d = e^(−1) mod (p−1) (q−1). Lastly, you should be able to decrypt m with the provided values of n and c, and the private key (d). If successfully decrypted, printing it should reveal the flag. Combining those steps in Python can look something like this:

from Crypto.Util.number import long_to_bytes, inverse    # handle converting large number back into og string and modular inverse

p = 10    # would be replaced with your actual p value
q = 10    # would be replaced with your actual q value
e = 3     # provided e value of this challenge

# provided c and n values
c = 121097813989638138346058726004363325645055007528244635971148694227865847570370087018045267126514696616744216943938638911580999312232908265654986785392772317303161096833644079741186123067589808986604522528602623394273539018995365633
n = 71896354804635857225166363589449270341827115454332860012713503557495411944286812684642863943328147304872982557075753482674200642780261278809144532575707907135774671448510840680384297418696170535811452102614271391881774706613609199833117497815024540283220195906969166186903921348355520476403579936432504673249

x = (p-1) * (q-1)    # compute x, Euler’s Totient Function, required for computing d
d = inverse(e, x)    # compute modular inverse

m = pow(c, d, n)     # decrypt ciphertext
print(long_to_bytes(m).decode())  # convert m to bytes, and print

If all the values are correct, running this script above should print out an encrypted message. This is an alternate method for solving this kind of challenge.

Grey Area

Christina Krypto's Diaries:
"I’ve always believed in pushing boundaries, in seeing beyond what’s already been done. But lately, as I dig deeper into this new factor—exploring encryption beyond the rigid structures I’ve known—I feel a creeping doubt. The lines between black and white, between what’s secure and what’s vulnerable, are starting to fade. It’s as if I’m trapped between two worlds."

Similar to the last challenge, the provided files for this one are named, output.txt and source․py. Looking at source․py we are able to see the public exponent (e) equals 0×10001 which is 65537 when converted from hexadecimal to decimal. In the output.txt we are also provided the n and c values.

#!/usr/bin/env python3
from secret import flag
from Crypto.Util.number import getPrime,bytes_to_long
p=getPrime(100)
q=getPrime(100)
n=p*q
e=0x10001
m=bytes_to_long(flag)

with open("output.txt", "w") as f:
    f.write(f"n = {n}\n")
    f.write(f"c = {pow(m,e,n)}")
    f.close()

This problem is similar to the last one, so I tried replacing the c and e values in the cube root attack code I wrote but it didn’t print anything to the terminal. The reason I assumed it wasn’t working was due to the size of the given values, so instead we are going to have to use the alternate attack method I discussed.

That means the first step is finding the p and q values of n, I used factordb for this since the n value given in this challenge was a lot smaller compared to the last one.

Entering n into the input field, spit out the values for p and q. I then took those values and copied them into a python program similar to what I showed previously, adjusting necessary values as required.

from Crypto.Util.number import long_to_bytes, inverse    # handle converting large number back into og string and modular inverse

p = 961538449797931913175965696759    # p value found through factoring n
q = 967319996044712948532221324531    # q value found through factoring n
e = 0x10001    # 65537

c = 311087069445751882711513305144448403394079226106736563961190    # provided c value
n = 930115369455374918279214292565370334148036289536095273895029    # provided n value
# n could also just be p * q

x = (p - 1) * (q - 1)    # compute x, Euler’s Totient Function, required for computing d
d = inverse(e, x)        # compute modular inverse

m = pow(c, d, n)    # decrypt ciphertext
print(long_to_bytes(m).decode())    # convert m to bytes, and print

Program starts by importing necessary libraries and initializing all of the provided or found values. From there it moves onto computing x, which is then used for computing d. Then using c, n, and d, it’s able to successfully decrypt the ciphertext and lastly print it out to the terminal.

Challenge flag: magpieCTF{wh1tp_n_blqck}

Inverse Converse

Christina Krypto's Diaries:
"My relationship with Professor Richard Hash is defined by an "inverse" dynamic—where our approaches to cryptography couldn’t be more opposite. While I believe in innovation and adaptability, he is firmly rooted in tradition, advocating for methods that have stood the test of time. Where I see flexibility and evolution, he sees instability and risk. Our perspectives mirror each other’s in a way: he’s focused on maintaining a static, tried-and-true system, while I push for constant progress, knowing that staying the same in the face of growing threats is the real danger. In this inverse, our work complements yet challenges one another—he strives for preservation, and I, for transformation. But ultimately, it’s this very contrast that drives the future of cryptography forward."

Just like the previous two challenges I discussed, the provided files for this challenge are also named, output.txt and source․py. However, unlike the last two where we were provided the c and n values, in this one we’re provided a value for c and k. The source․py code looks like this:

from secret import flag
from Crypto.Util.number import bytes_to_long
from Crypto.Util.number import getPrime

m=bytes_to_long(flag)
bits=len(bin(m)[2:])
k=getPrime(bits+16)
n=0x1337
with open("output.txt",'w') as f:
    f.write(f"c = {m*n%k}\n")
    f.write(f"k = {k}")
    f.close()

Looking at the code gives us a new value to work with, and some info about c and k.

  • The value of n, 0×1337, converting it from hexadecimal to decimal you get, 4919

  • k is a prime number

  • c is calculated as (m * n) mod k

Those last two points are extremely useful for knowing the necessary formula needed for solving for m, and the value of n is required in the formula, which is, m = [c * n^(−1)] mod k. Now knowing the formula, we can throw together a Python program:

from Crypto.Util.number import long_to_bytes, inverse    # handle converting large number back into og string and modular inverse

# provided c and k values
c = 4082963394707349379691940087749373560022452272321719622709503732828753479223853620855771
k = 59936462153071551643692430070494658880579757984426138973968053464181955001212203124978301
n = 0x1337    # 4919

x = inverse(n, k)    # compute modular inverse
m = (c * x) % k      # solve for m and recover the original message

flag = long_to_bytes(m)  # convert m to bytes to get the flag
print(flag.decode())     # print the flag

The program starts off by importing the necessary libraries and storing the given values. It computes the modular inverse of n and k, storing it in x. Then using c, x, and k, it plugs them into the formula mentioned earlier to recover m. Lastly converting and printing it out.

Challenge flag: magpieCTF{1nv3rs3_cr34t3s_fr1ct10n}


Wrap Up

Those weren’t the only challenges I attempted, they were just the only ones I had solved. I tried almost all of the crypto and forensics challenges but I couldn’t finish a handful of them before I ran out of time. Some memorable ones included Corrupted image and s3cr3ts pl4ns from the forensics category, and ShadowWizzards and Resolve in Harmony from the crypto category.

An honorable mention goes to the miscellaneous challenge, under-the-micro-scope. I spent more time on this challenge than I’d like to admit and I just couldn’t figure out what it said besides ‘magpieCTF{ }‘, no matter what I did when editing the photo I just couldn’t read what was between the brackets.


Conclusion

Overall, MagpieCTF was a lot of fun, and a great learning experience. Crypto and forensics are by far some of my favourite types of challenges, so I’m glad that I got to test my knowledge on some tough ones. Considering the number of people who competed, there was some strong competition, and even though we weren’t going out there to be competitive, we still finished 28th out of 73 teams - which I couldn’t be happier about.

0
Subscribe to my newsletter

Read articles from Denton directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Denton
Denton

Current full-time college student going to school for Information Systems Security. Always had a passion for programming, which ultimately led into a passion for cybersecurity.