Published on

I Solved a Stegano-Based CTF Hidden in a PNG. Here’s How.

views
Authors

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

Image of Website

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.

References: