- Published on
I Solved a Stegano-Based CTF Hidden in a PNG. Here’s How.
- Authors
- Name
- Haider Ali Punjabi
- @HAliPunjabi
During the evening of Good Friday 2025, I was browsing Reddit and saw a post (now deleted) about an Easter themed Capture the Flag event. While I'm not particularly experienced with CTFs, I love diving into these challenges and learning along the way. Also, ChatGPT was very helpful in teaching me stuff. (I don't think I can identify encryption algorithms on my own) I did a similar Count to a Million Challenge a few months ago.
The Reddit post was a link to a LinkedIn Post by Kieran O'Reilly, Co-Founder of Knot.
My setup
When I first saw the challenge, I thought it will be simple enough and decided to solve it on my main Ubuntu VM. Before continuing on Day 2, I decided to create a separate LXC for it so I could install any package I wanted without it affecting my VM. Thank you Proxmox.
The Challenge - Day 1

Clue 1
The post described the challenge and the first clue was a Base64 encoded string which when decoded contained the URL to the challenge website.
Clue
aHR0cHM6Ly9lYXN0ZXItY2hhbGxlbmdlLnN0YWdpbmcua25vdGFwaS5jb20v
Code
import base64
encoded = "aHR0cHM6Ly9lYXN0ZXItY2hhbGxlbmdlLnN0YWdpbmcua25vdGFwaS5jb20v"
decoded = base64.b64decode(encoded).decode("utf-8")
print(decoded)
Answer
https://easter-challenge.staging.knotapi.com/
Clue 2
On the website, there was a small poem
EASTER's the name, now don't you stress,
The login page is a simple guess.
The puzzle and cookie are tightly bound,
Mismatch them and you won't be allowed.
This was an easy clue - I needed to find a login page, which was a simple guess (/login
). The username would be EASTER - another simple guess. The real CTF challenge was figuring out the password.
Clue 3
Next, it was time to dig a bit deeper. I opened up the DevTools and saw the source of the webpage and the requests it was making. It wasn't doing much except loading an HTML page, and a couple of fonts and one Image (the most important part of the puzzle).
The next clue was hidden in plain sight - as a comment in the HTML Code. It was another Base64 encoded string, but this time it wasn't just base64 — it was also gzip-compressed and ROT13-encrypted. After deciphering I got my next clue - another small poem.
In the Networks tab, I saw that a Cookie called CHALLENGE_JWE was being set on every page load. This meant that everyone will be getting different clues, and hence different passwords for the login.
Clue
H4sIAGUcAWgA/x3MMQ7CMAyF4T2neAeIegwYkRjY4yp2hSqjuolpOnENrsdJGrq85Xv6LwTRzaAYzbaI1yRoRBs8oelsmWN4yO/zZSQSvCcWrI2ef4CV0pcyxnxeh3Ctzrgri2VoFuw6g812jNRcLIZb7zPtJtVTObPVl8USqAOtYC9VhgOvi3RPlgAAAA==
Code
import base64
import gzip
import io
import codecs
data = "H4sIAGUcAWgA/x3MMQ7CMAyF4T2neAeIegwYkRjY4yp2hSqjuolpOnENrsdJGrq85Xv6LwTRzaAYzbaI1yRoRBs8oelsmWN4yO/zZSQSvCcWrI2ef4CV0pcyxnxeh3Ctzrgri2VoFuw6g812jNRcLIZb7zPtJtVTObPVl8USqAOtYC9VhgOvi3RPlgAAAA=="
decoded_data = base64.b64decode(data)
with gzip.GzipFile(fileobj=io.BytesIO(decoded_data)) as f:
result = f.read().decode('utf-8')
decoded = codecs.decode(result, 'rot_13')
print(decoded)
Answer
So take a peek, but look in layers,
It’s not just flowers, eggs, or prayers.
This Easter art may seem polite,
But something’s hidden out of sight.
This was when I wasted a lot of time playing around with the image to see if it revealed anything. I messed around with a lot of controls in GIMP but couldn't find anything. I also tried basic file inspection - Metadata, Exif and some packages like binwalk
or commands like strings
to see if I could find a clue. I also read about a package zsteg but saw that it needed an installation of Ruby, which I didn't want to install on my main VM, so I decided to stop and went to bed.
Day 2
It was a Saturday now and I was still curious about the challenge. I opened up the Reddit and LinkedIn post to see if someone had solved it. There were partial solutions, which did help me but no one had finished the challenge yet. Someone had successfully used zsteg
to get the next clues so I decided to continue working on the challenge.
Clue 4
It took me a while to find the next clue. I did have help from Reddit but wanted to follow all steps on my own, so that I could do a write up explaining everything and I had a suspicion that some clues were being dynamically generated on every page load.
Using zsteg, I confirmed the presence of a hidden ZIP file (as hinted on Reddit) and extracted it using its offset.
Clue
[?] 6670203 bytes of extra data after image end (IEND), offset = 0xdcf09
Note: The offset here can be different for different sessions, and needs to be used in the given code
Code
with open("art.png", "rb") as f:
f.seek(0xdcf09) # jump to offset where the hidden data starts
hidden_data = f.read()
# Save the hidden data to a file for further analysis
with open("hidden_data.zip", "wb") as out:
out.write(hidden_data)
Clue 5
The ZIP was password protected and I knew it needed a password which was unique, so I couldn't just copy the one on Reddit. The user on Reddit mentioned they found the password using zsteg
so I started playing around with the flags to get the most detailed output. Finally zsteg -a -v art.png
gave me a detailed output along which had the password in it.
Using the password I extracted two files from the zip - a stub
which was an executable and a config.pkl.enc
which had some encrypted info in it. The last help I got from Reddit was I knew I had to somehow decompile this executable stub
to get a python source code.
I found a tool called pyinstxtractor that decompiled the stub into a bunch of files, which contained ghxst.pyc
, a compiled Python file that I needed to decompile.
I used a package called uncompyle6 to do the decompilation and got a ghxst.py
file which had my next clues - a decrypt_msg
function that I had to implement, and a riddle like poem. Also, there were some small errors in the code that needed to be fixed as well.
The cipher echoes back your voice,
But hides the twist within your choice.
You speak the words you know are true —
And hear them back, disguised as new.
Yet echo twice, and what you'll find
Is key to what was left behind.
My understanding of this clue was - whatever clue I got from implementing the decrypt_msg
function, would need to be used with the Cookie (what I left behind) to get the next clue. I was wrong.
ghstx.py
# uncompyle6 version 3.9.2
# Python bytecode version base 3.8.0 (3413)
# Decompiled from: Python 3.12.3 (main, Feb 4 2025, 14:48:35) [GCC 13.3.0]
# Embedded file name: ghxst.py
import os, sys, pickle
from Cryptodome.Cipher import AES
AES_KEY = bytes.fromhex("38dfa220186cecf74167fb001c78e54f898ed61a1c9a931eeb3b8e00ab600ba0")
def decrypt_cfg(enc_data: bytes) -> bytes:
nonce = enc_data[None[:12]]
ct = enc_data[12[:None]]
cipher = AES.new(AES_KEY, (AES.MODE_GCM), nonce=nonce)
return cipher.decrypt(ct)
def decrypt_msg(message, key):
raise NotImplementedError()
def main():
exe_dir = os.path.dirname(sys.executable)
pkl_path = os.path.join(exe_dir, "config.pkl.enc")
with open(pkl_path, "rb") as f:
data = f.read()
data = decrypt_cfg(data)
cfg = pickle.loads(data)
encrypted = cfg["encrypted"]
known_key = cfg["known_key"]
print("\n The cipher echoes back your voice,\n But hides the twist within your choice.\n You speak the words you know are true —\n And hear them back, disguised as new.\n Yet echo twice, and what you'll find\n Is key to what was left behind.\n ")
key = decrypt_msg(encrypted, known_key)
print(key.decode())
if __name__ == "__main__":
main()
Clue 6
The ghsxt.py
code extracted a Message and a Key from the config.pkl.enc
file. I had to decrypt the message using the key to get the next clue. This took me a very long time to do. Even though I correctly decrypted the message multiple times, I was stuck because I assumed the result needed to be used to manually decrypt the JWE cookie — which wasn’t the case.
Code
def decrypt_msg(message, key):
return bytes([m ^ k for m, k in zip(message, key.encode())])
Answer
XMSG_iaknakm9_jzToHwA39VzH6j7Q
Solution
Finally I decided to take the answer directly and use it as the password, and finally succeeded in logging in. It turns out that the challenge server handles cookie decryption internally using the decrypted result — I just needed to provide the right password to log in.
Final Words
This was a fun challenge, I learnt about new tools like zsteg
which even though I don't think I would ever use in my day to day work, is one of those interesting things that I may end up using in an unexpected manner some day.
The challenge was also well designed, especially the part where clues are generated dynamically for every session, so no one can just share the final answer. I hope the website stays up, I would love to use it in the future as a learning tool.